diff --git a/apps/design-system/package.json b/apps/design-system/package.json index bad4ec8c6bea8..9636c6b1dd676 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/docs/content/guides/telemetry/sentry-monitoring.mdx b/apps/docs/content/guides/telemetry/sentry-monitoring.mdx index d2c8a9eb3acbb..2c21ef0048b21 100644 --- a/apps/docs/content/guides/telemetry/sentry-monitoring.mdx +++ b/apps/docs/content/guides/telemetry/sentry-monitoring.mdx @@ -3,121 +3,82 @@ title: 'Sentry integration' subtitle: 'Integrate Sentry to monitor errors from a Supabase client' --- -You can use [Sentry](https://sentry.io/welcome/) to monitor errors thrown from a Supabase JavaScript client. Install the [Supabase Sentry integration](https://github.com/supabase-community/sentry-integration-js) to get started. +You can use [Sentry](https://sentry.io/welcome/) to monitor errors thrown from a Supabase JavaScript client. Support for Supabase is built directly into the Sentry JavaScript SDK. -The Sentry integration supports browser, Node, and edge environments. - -## Installation - -Install the Sentry integration using your package manager: - - - - - -```sh -npm install @supabase/sentry-js-integration -``` - - - - - -```sh -yarn add @supabase/sentry-js-integration -``` - - - - - -```sh -pnpm add @supabase/sentry-js-integration -``` - - - - - -## Use +The integration instruments database queries and authentication calls made through `supabase-js`, creating spans for performance monitoring and capturing errors. It supports browser, Node, and edge environments. -If you are using Sentry JavaScript SDK v7, reference [`supabase-community/sentry-integration-js` repository](https://github.com/supabase-community/sentry-integration-js/blob/master/README-v7.md) instead. +The built-in integration requires Sentry JavaScript SDK **v9.14.0 or later**. If you're on an older SDK (including v7), use the community [`@supabase/sentry-js-integration`](https://github.com/supabase-community/sentry-integration-js) package instead, which the built-in integration is based on. -To use the Supabase Sentry integration, add it to your `integrations` list when initializing your Sentry client. +## Use + +There are two ways to enable the integration. Both take an initialized Supabase client instance. -You can supply either the Supabase Client constructor or an already-initiated instance of a Supabase Client. + - + - +Add `supabaseIntegration` to the `integrations` list when you initialize Sentry. Use this when your `Sentry.init` call and your Supabase client live in the same place. ```ts import * as Sentry from '@sentry/browser' -import { SupabaseClient } from '@supabase/supabase-js' -import { supabaseIntegration } from '@supabase/sentry-js-integration' +import { createClient } from '@supabase/supabase-js' + +const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY) Sentry.init({ dsn: SENTRY_DSN, + tracesSampleRate: 1.0, integrations: [ - supabaseIntegration(SupabaseClient, Sentry, { - tracing: true, - breadcrumbs: true, - errors: true, - }), + Sentry.browserTracingIntegration(), + Sentry.supabaseIntegration({ supabaseClient }), ], }) ``` - + + +Call `Sentry.instrumentSupabaseClient` where you create the client. This is the better fit for frameworks (like Next.js) where `Sentry.init` runs in a separate config file. Instrument each client you create: it patches database calls per runtime (browser, server, edge) and auth calls per client instance, so setups that create a client per request (like `@supabase/ssr`) must instrument each one. See the Next.js example below. ```ts import * as Sentry from '@sentry/browser' import { createClient } from '@supabase/supabase-js' -import { supabaseIntegration } from '@supabase/sentry-js-integration' -const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY) +export const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY) -Sentry.init({ - dsn: SENTRY_DSN, - integrations: [ - supabaseIntegration(supabaseClient, Sentry, { - tracing: true, - breadcrumbs: true, - errors: true, - }), - ], -}) +Sentry.instrumentSupabaseClient(supabaseClient) ``` -All available configuration options are available in our [`supabase-community/sentry-integration-js` repository](https://github.com/supabase-community/sentry-integration-js/blob/master/README.md#options). + + +By default, query filters and mutation bodies are redacted from spans and breadcrumbs. To capture them, pass `sendOperationData: true` where you set up instrumentation (`Sentry.supabaseIntegration({ supabaseClient, sendOperationData: true })` or `Sentry.instrumentSupabaseClient(client, { sendOperationData: true })`), or enable `dataCollection: { userInfo: true }` in your `Sentry.init` options, which applies to every client in that runtime. + + ## Deduplicating spans -If you're already monitoring HTTP errors in Sentry, for example with the HTTP, Fetch, or Undici integrations, you will get duplicate spans for Supabase calls. You can deduplicate the spans by skipping them in your other integration: +Sentry's HTTP and Fetch tracing integrations are enabled by default in the Node and Next.js SDKs, so the underlying Supabase REST calls are traced as `http.client` spans in addition to the `db` spans from the Supabase integration. This is optional cleanup: if you'd rather not see both, skip the Supabase REST requests in your other integration. ```ts import * as Sentry from '@sentry/browser' -import { SupabaseClient } from '@supabase/supabase-js' -import { supabaseIntegration } from '@supabase/sentry-js-integration' +import { createClient } from '@supabase/supabase-js' + +const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY) Sentry.init({ dsn: SENTRY_DSN, + tracesSampleRate: 1.0, integrations: [ - supabaseIntegration(SupabaseClient, Sentry, { - tracing: true, - breadcrumbs: true, - errors: true, - }), + Sentry.supabaseIntegration({ supabaseClient }), // @sentry/browser Sentry.browserTracingIntegration({ @@ -126,23 +87,14 @@ Sentry.init({ }, }), - // or @sentry/node - Sentry.httpIntegration({ - tracing: { - ignoreOutgoingRequests: (url) => { - return url.startsWith(`${SUPABASE_URL}/rest`) - }, - }, - }), - - // or @sentry/node with Fetch support + // or @sentry/node (supabase-js uses fetch, so filter the Fetch integration) Sentry.nativeNodeFetchIntegration({ ignoreOutgoingRequests: (url) => { return url.startsWith(`${SUPABASE_URL}/rest`) }, }), - // or @sentry/WinterCGFetch for Next.js Proxy & Edge Functions + // or @sentry/nextjs for Proxy & Edge Functions Sentry.winterCGFetchIntegration({ breadcrumbs: true, shouldCreateSpanForRequest: (url) => { @@ -153,125 +105,28 @@ Sentry.init({ }) ``` -## Example Next.js configuration - -See this example for a setup with Next.js to cover browser, server, and edge environments. First, run through the [Sentry Next.js wizard](https://docs.sentry.io/platforms/javascript/guides/nextjs/#install) to generate the base Next.js configuration. Then add the Supabase Sentry Integration to all your `Sentry.init` calls with the appropriate filters. - - - - - -```ts sentry.client.config.ts -import * as Sentry from '@sentry/nextjs' -import { SupabaseClient } from '@supabase/supabase-js' -import { supabaseIntegration } from '@supabase/sentry-js-integration' - -Sentry.init({ - dsn: SENTRY_DSN, - integrations: [ - supabaseIntegration(SupabaseClient, Sentry, { - tracing: true, - breadcrumbs: true, - errors: true, - }), - Sentry.browserTracingIntegration({ - shouldCreateSpanForRequest: (url) => { - return !url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`) - }, - }), - ], - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: true, -}) -``` - - +## Configuration for Next.js - +Next.js runs Sentry across browser, server, and edge runtimes, and auth-aware setups (like `@supabase/ssr`) create a Supabase client per request. Since `instrumentSupabaseClient` patches database calls per runtime and auth calls per client instance, call it inside each of your client factories rather than on a single shared instance. -```ts sentry.server.config.ts -import * as Sentry from '@sentry/nextjs' -import { SupabaseClient } from '@supabase/supabase-js' -import { supabaseIntegration } from '@supabase/sentry-js-integration' +1. Run through the [Sentry Next.js wizard](https://docs.sentry.io/platforms/javascript/guides/nextjs/#install) to set up the base Sentry configuration. -Sentry.init({ - dsn: SENTRY_DSN, - integrations: [ - supabaseIntegration(SupabaseClient, Sentry, { - tracing: true, - breadcrumbs: true, - errors: true, - }), - Sentry.nativeNodeFetchIntegration({ - breadcrumbs: true, - ignoreOutgoingRequests: (url) => { - return url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`) - }, - }), - ], - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: true, -}) -``` - - - - +2. Add `Sentry.instrumentSupabaseClient` to each factory. For example, the server client with `@supabase/ssr`: -```js sentry.edge.config.ts +```ts utils/supabase/server.ts import * as Sentry from '@sentry/nextjs' -import { SupabaseClient } from '@supabase/supabase-js' -import { supabaseIntegration } from '@supabase/sentry-js-integration' +import { createServerClient } from '@supabase/ssr' -Sentry.init({ - dsn: SENTRY_DSN, - integrations: [ - supabaseIntegration(SupabaseClient, Sentry, { - tracing: true, - breadcrumbs: true, - errors: true, - }), - Sentry.winterCGFetchIntegration({ - breadcrumbs: true, - shouldCreateSpanForRequest: (url) => { - return !url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`) - }, - }), - ], - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: true, -}) -``` +export async function createClient() { + const client = createServerClient(/* your usual URL, key, and cookie config */) - - - - -```js instrumentation.ts -// https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation -export async function register() { - if (process.env.NEXT_RUNTIME === 'nodejs') { - await import('./sentry.server.config') - } - - if (process.env.NEXT_RUNTIME === 'edge') { - await import('./sentry.edge.config') - } + Sentry.instrumentSupabaseClient(client) + return client } ``` - +3. Apply the same in your browser client using (`createBrowserClient`) and middleware client so every runtime is covered. - +4. To include query filters and mutation bodies, enable `dataCollection: { userInfo: true }` in each runtime's Sentry config, or pass `Sentry.instrumentSupabaseClient(client, { sendOperationData: true })` at the call site. -Afterwards, build your application (`npm run build`) and start it locally (`npm run start`). You will now see the transactions being logged in the terminal when making supabase-js requests. +5. Build and run your application (`npm run build && npm run start`). Supabase queries now appear as `db` spans in your Sentry traces. diff --git a/apps/docs/package.json b/apps/docs/package.json index 11a8b9a54ff34..8f2b95802220a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -181,6 +181,7 @@ "tsconfig": "workspace:*", "tsx": "catalog:", "twoslash": "^0.3.1", + "@typescript/native": "catalog:", "typescript": "catalog:", "unist-util-visit-parents": "5.1.3", "vite": "catalog:", diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 5926095fa2105..3257114252d35 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -3,7 +3,7 @@ "react-hooks/exhaustive-deps": 161, "import/no-anonymous-default-export": 57, "@tanstack/query/exhaustive-deps": 9, - "@typescript-eslint/no-explicit-any": 898, + "@typescript-eslint/no-explicit-any": 897, "no-restricted-imports": 0, "no-restricted-exports": 198, "react/no-unstable-nested-components": 38, @@ -556,7 +556,6 @@ "hooks/misc/withAuth.tsx": 1, "hooks/ui/useClickedOutside.ts": 2, "hooks/ui/useFlag.ts": 1, - "instrumentation-client.ts": 3, "lib/ai/generate-assistant-response.ts": 2, "lib/ai/model.ts": 2, "lib/ai/model.utils.ts": 3, @@ -571,6 +570,7 @@ "lib/pg-format.ts": 6, "lib/profile.tsx": 1, "lib/role-impersonation.ts": 1, + "lib/sentry-client-options.ts": 2, "lib/telemetry/track.ts": 1, "pages/_app.tsx": 1, "pages/api/ai/feedback/rate.ts": 2, diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx index 26a0b03bd9896..1789962add40d 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Error.tsx @@ -18,13 +18,11 @@ export function ApiAuthorizationErrorScreen({
- Retry the authorization request from the requesting app. - {error && ( - Error: {error.message} - )} - + error && ( + Error: {error.message} + ) } /> , + ]} /> ) } diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx index df6e42f282ed8..66c2f0c7c519c 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 ebae4de5b4cff..54680d64291a4 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 20d63b4593a19..179baa81dd552 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 0000000000000..31d24422693b5 --- /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 1cdb5b0c3b030..c256c2ffac20e 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/Home/ProjectList/ProjectCard.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index 1aa338ab6d21d..7923befbe2b42 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -78,7 +78,7 @@ export const ProjectCard = ({
-
{name}
+

{name}

e.preventDefault()}> @@ -91,6 +91,7 @@ export const ProjectCard = ({ e.preventDefault() }} onPointerDown={(e) => e.stopPropagation()} + aria-label={`Project ${name} actions`} /> diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx index 3ab9ae62a6b37..a8e20c56d36b0 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx @@ -12,6 +12,9 @@ import { DropdownMenuTrigger, TableCell, TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { TimestampInfo } from 'ui-patterns/TimestampInfo' @@ -81,29 +84,34 @@ export const ProjectTableRow = ({
-
{name}
- +

{name}

+ + + + + Copy project reference +
{(isGithubIntegrated || isVercelIntegrated || hasPartnerIcon) && (
@@ -175,7 +183,7 @@ export const ProjectTableRow = ({
+ ) +} 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 0000000000000..87ce9d5e08b00 --- /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 7164ea180ec08..ed3809fbc56c9 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/Integrations/GithubIntegration/GitHubRepositoryField.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx index 4bc48dd12e9df..5d5d0b5fb8456 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx @@ -133,10 +133,11 @@ export const GitHubRepositoryField = ({ control={form.control} name={name} render={({ field }) => ( - + {gitHubAuthorization === null ? (
- { - 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 7ca8624837a88..c1959e98717aa 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 && ( - tab.id)} + strategy={horizontalListSortingStrategy} > - -
- 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 }} + {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') + }} + > + + {' '} +
+ )} - -
- - - - - {tabs.activeTab ? : null} - - + + + {!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/data/table-rows/table-rows-count-query.ts b/apps/studio/data/table-rows/table-rows-count-query.ts index 3933afd0c73d2..111be34e74cbb 100644 --- a/apps/studio/data/table-rows/table-rows-count-query.ts +++ b/apps/studio/data/table-rows/table-rows-count-query.ts @@ -1,4 +1,5 @@ import { getTableRowsCountSql } from '@supabase/pg-meta' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query' import { IS_PLATFORM } from 'common' @@ -9,6 +10,7 @@ import type { Filter, SupaTable } from '@/components/grid/types' import { useConnectionStringForReadOps } from '@/data/read-replicas/replicas-query' import { executeSql } from '@/data/sql/execute-sql-mutation' import { prefetchTableEditor } from '@/data/table-editor/table-editor-query' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { RoleImpersonationState, wrapWithRoleImpersonation } from '@/lib/role-impersonation' import { isRoleImpersonationEnabled } from '@/state/role-impersonation-state' import { ResponseError, UseCustomQueryOptions } from '@/types' @@ -44,8 +46,8 @@ export async function getTableRowsCount( filters, roleImpersonationState, enforceExactCount, - isUsingReadReplica = false, - }: TableRowsCountVariables & { isUsingReadReplica?: boolean }, + isReadOnlyContext = false, + }: TableRowsCountVariables & { isReadOnlyContext?: boolean }, signal?: AbortSignal ) { const entity = await prefetchTableEditor(queryClient, { @@ -65,7 +67,7 @@ export async function getTableRowsCount( table, filters: formattedFilters, enforceExactCount, - isUsingReadReplica, + isReadOnlyContext, }), roleImpersonationState ) @@ -103,6 +105,10 @@ export const useTableRowsCountQuery = ( identifier: readReplicaIdentifier, type, } = useConnectionStringForReadOps() + const { can: canSQLAdminWrite } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'tables' + ) return useQuery({ queryKey: tableRowKeys.tableRowsCount(projectRef, { @@ -117,7 +123,7 @@ export const useTableRowsCountQuery = ( projectRef, connectionString, tableId, - isUsingReadReplica: type === 'replica', + isReadOnlyContext: type === 'replica' || !canSQLAdminWrite, ...args, }, signal diff --git a/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts b/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts index f68a28dd2a3b3..b82728b422710 100644 --- a/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts +++ b/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts @@ -2,29 +2,77 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { useEnabledIdentityProviders } from '../useEnabledIdentityProviders' -import { GITHUB_IDENTITY_PROVIDER } from '@/lib/external-identity-providers' +import { + CHATGPT_IDENTITY_PROVIDER, + GITHUB_IDENTITY_PROVIDER, +} from '@/lib/external-identity-providers' const mockIsFeatureEnabled = vi.hoisted(() => vi.fn()) +const mockUseLocalStorageQuery = vi.hoisted(() => vi.fn()) vi.mock('../useIsFeatureEnabled', () => ({ useIsFeatureEnabled: mockIsFeatureEnabled, })) +vi.mock('../useLocalStorage', () => ({ + useLocalStorageQuery: mockUseLocalStorageQuery, +})) + describe('useEnabledIdentityProviders', () => { it('returns every provider when all flags are enabled', () => { mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: true, + dashboardAuthSignInWithChatgpt: true, }) + mockUseLocalStorageQuery.mockReturnValue([true]) const { result } = renderHook(() => useEnabledIdentityProviders()) - expect(result.current).toEqual([GITHUB_IDENTITY_PROVIDER]) + expect(result.current).toEqual([GITHUB_IDENTITY_PROVIDER, CHATGPT_IDENTITY_PROVIDER]) }) it('returns no providers when all flags are disabled', () => { mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: false, + dashboardAuthSignInWithChatgpt: false, + }) + mockUseLocalStorageQuery.mockReturnValue([false]) + + const { result } = renderHook(() => useEnabledIdentityProviders()) + + expect(result.current).toEqual([]) + }) + + it('includes ChatGPT when its flag is enabled and the local storage switch is truthy', () => { + mockIsFeatureEnabled.mockReturnValue({ + dashboardAuthSignInWithGithub: false, + dashboardAuthSignInWithChatgpt: true, + }) + mockUseLocalStorageQuery.mockReturnValue([true]) + + const { result } = renderHook(() => useEnabledIdentityProviders()) + + expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER]) + }) + + it('excludes ChatGPT when its flag is enabled but the local storage switch is unset', () => { + mockIsFeatureEnabled.mockReturnValue({ + dashboardAuthSignInWithGithub: false, + dashboardAuthSignInWithChatgpt: true, + }) + mockUseLocalStorageQuery.mockReturnValue([false]) + + const { result } = renderHook(() => useEnabledIdentityProviders()) + + expect(result.current).toEqual([]) + }) + + it('excludes ChatGPT when the local storage switch is truthy but its flag is disabled', () => { + mockIsFeatureEnabled.mockReturnValue({ + dashboardAuthSignInWithGithub: false, + dashboardAuthSignInWithChatgpt: false, }) + mockUseLocalStorageQuery.mockReturnValue([true]) const { result } = renderHook(() => useEnabledIdentityProviders()) diff --git a/apps/studio/hooks/misc/useEnabledIdentityProviders.ts b/apps/studio/hooks/misc/useEnabledIdentityProviders.ts index 4823f9117b348..83aa7c1b18df7 100644 --- a/apps/studio/hooks/misc/useEnabledIdentityProviders.ts +++ b/apps/studio/hooks/misc/useEnabledIdentityProviders.ts @@ -1,7 +1,10 @@ +import { LOCAL_STORAGE_KEYS } from 'common' import { useMemo } from 'react' import { useIsFeatureEnabled } from './useIsFeatureEnabled' +import { useLocalStorageQuery } from './useLocalStorage' import { + CHATGPT_IDENTITY_PROVIDER, GITHUB_IDENTITY_PROVIDER, type ExternalIdentityProviderConfig, } from '@/lib/external-identity-providers' @@ -10,11 +13,29 @@ import { * Returns the statically-declared identity providers whose feature flag is currently enabled. * To add a provider: declare its config in `lib/external-identity-providers.ts`, add a * `dashboard_auth:sign_in_with_*` flag, and gate it here. + * + * ChatGPT is a deliberate exception: it's also gated behind a manual, localStorage-only rollout + * switch (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`) on top of its feature flag, since it's WIP. */ export function useEnabledIdentityProviders(): ExternalIdentityProviderConfig[] { const { dashboardAuthSignInWithGithub: githubEnabled } = useIsFeatureEnabled([ 'dashboard_auth:sign_in_with_github', ]) - return useMemo(() => [...(githubEnabled ? [GITHUB_IDENTITY_PROVIDER] : [])], [githubEnabled]) + const { dashboardAuthSignInWithChatgpt: chatgptEnabled } = useIsFeatureEnabled([ + 'dashboard_auth:sign_in_with_chatgpt', + ]) + const [chatgptLocalStorageEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED, + false + ) + + return useMemo( + () => + [ + githubEnabled && GITHUB_IDENTITY_PROVIDER, + chatgptEnabled && chatgptLocalStorageEnabled && CHATGPT_IDENTITY_PROVIDER, + ].filter((p): p is ExternalIdentityProviderConfig => Boolean(p)), + [githubEnabled, chatgptEnabled, chatgptLocalStorageEnabled] + ) } diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index 0097516a418c6..132068af7e900 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -1,314 +1,22 @@ -// This file configures the initialization of Sentry on the client. -// The config you add here will be used whenever a user loads a page in their browser. +// This file configures the initialization of Sentry on the client for the +// NEXT build — Next auto-loads it whenever a user loads a page in their +// browser. The TanStack Start (Vite) build never loads Next convention files; +// it initializes Sentry with the same shared options in +// sentry.tanstack.ts instead. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from '@sentry/nextjs' -import { hasConsented } from 'common' -import { IS_PLATFORM } from 'common/constants/environment' -import { MIRRORED_BREADCRUMBS } from '@/lib/breadcrumbs' -import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from '@/lib/sanitize' +import { buildSentryClientOptions } from '@/lib/sentry-client-options' -const DEFAULT_ERROR_SAMPLE_RATE = 1.0 -const LOW_PRIORITY_ERROR_SAMPLE_RATE = 0.01 -const CHUNK_LOAD_ERROR_PATTERNS = [ - /ChunkLoadError/i, - /Loading chunk [\d]+ failed/i, - /Loading CSS chunk [\d]+ failed/i, -] - -// This is a workaround to ignore hCaptcha related errors. -function isHCaptchaRelatedError(event: Sentry.Event): boolean { - const errors = event.exception?.values ?? [] - for (const error of errors) { - if ( - error.value?.includes('is not a function') && - error.stacktrace?.frames?.some((f) => f.filename === 'api.js') - ) { - return true - } - } - return false -} - -// Filter browser wallet extension errors (e.g., Gate.io wallet) -// These errors come from injected wallet scripts and are not actionable -// Examples: SUPABASE-APP-AFC, SUPABASE-APP-92A -export function isBrowserWalletExtensionError(event: Sentry.Event): boolean { - const frames = event.exception?.values?.flatMap((e) => e.stacktrace?.frames || []) || [] - return frames.some((frame) => { - const filename = frame.filename || frame.abs_path || '' - return filename.includes('gt-window-provider') || filename.includes('wallet-provider') +Sentry.init( + buildSentryClientOptions({ + // next.config.ts (withSentryConfig) annotates the bundles with the + // 'supabase-studio' applicationKey, so third-party frame tagging works + // on this build. + includeThirdPartyErrorFilter: true, }) -} - -// Filter user-aborted operations (intentional cancellations) -// These are expected when users cancel requests or navigate away -// Examples: SUPABASE-APP-BG6, SUPABASE-APP-BG7 -export function isUserAbortedOperation(error: unknown, event: Sentry.Event): boolean { - const errorMessage = error instanceof Error ? error.message : '' - const eventMessage = event.message || '' - const message = errorMessage || eventMessage - - return ( - message.includes('operation was aborted') || - message.includes('signal is aborted') || - message.includes('manually canceled') || - message.includes('AbortError') - ) -} - -// Filter cancellation promise rejections (e.g., from query cancellation) -// These occur when operations are intentionally cancelled by the user -// Example: SUPABASE-APP-353 (~466k events) -export function isCancellationRejection(event: Sentry.Event): boolean { - const serialized = event.extra?.__serialized__ as Record | undefined - return serialized?.type === 'cancelation' -} - -// Filter challenge/captcha expired errors (user timeout) -// These happen when users don't complete captcha in time - expected behavior -// Example: SUPABASE-APP-ACC -export function isChallengeExpiredError(error: unknown, event: Sentry.Event): boolean { - const errorMessage = error instanceof Error ? error.message : '' - const eventMessage = event.message || '' - const message = errorMessage || eventMessage - - return message.includes('challenge-expired') -} - -function isChunkLoadError(error: unknown, event: Sentry.Event): boolean { - const errorMessage = error instanceof Error ? error.message : '' - const eventMessage = event.message || '' - const exceptionMessages = event.exception?.values?.map((ex) => ex.value ?? '') ?? [] - const combinedMessages = [errorMessage, eventMessage, ...exceptionMessages].filter(Boolean) - - return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => - combinedMessages.some((message) => pattern.test(message)) - ) -} - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - ...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && { - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, - }), - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - // Enable performance monitoring - tracesSampleRate: 0.02, - - integrations: (() => { - const thirdPartyErrorFilterIntegration = (Sentry as any).thirdPartyErrorFilterIntegration - if (!thirdPartyErrorFilterIntegration) return [] - - // Tag errors whose stack trace only contains third-party frames (browser extensions, - // injected scripts, etc.). This uses build-time code annotation via the applicationKey - // in next.config.ts to reliably distinguish our code from third-party code. - // We use 'apply-tag' instead of 'drop' so that beforeSend can exempt error boundary - // crashes — these may originate in third-party code but are caused by first-party bugs. - return [ - thirdPartyErrorFilterIntegration({ - filterKeys: ['supabase-studio'], - behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', - }), - ] - })(), - - // Only capture errors originating from our own code. - // This is a whitelist on the source URL in stack frames — it drops errors from - // browser extensions, injected scripts, third-party widgets, etc. (FE-2094) - allowUrls: [ - /https?:\/\/(.*\.)?supabase\.(com|co|green|io)/, - /app:\/\//, // Next.js rewrites source URLs to app:// with source maps - ], - beforeBreadcrumb(breadcrumb, _hint) { - const cleanedBreadcrumb = { ...breadcrumb } - - if (cleanedBreadcrumb.category === 'navigation') { - if (typeof cleanedBreadcrumb.data?.from === 'string') { - cleanedBreadcrumb.data.from = sanitizeUrlHashParams(cleanedBreadcrumb.data.from) - } - if (typeof cleanedBreadcrumb.data?.to === 'string') { - cleanedBreadcrumb.data.to = sanitizeUrlHashParams(cleanedBreadcrumb.data.to) - } - } - - MIRRORED_BREADCRUMBS.pushBack(cleanedBreadcrumb) - return cleanedBreadcrumb - }, - beforeSend(event, hint) { - const consent = hasConsented() - - if (!consent) { - return null - } - - if (!IS_PLATFORM) { - return null - } - - const isErrorBoundaryCrash = - event.tags?.globalErrorBoundary === true || event.tags?.globalErrorBoundary === 'true' - const isThirdPartyOnly = - event.tags?.third_party_code === true || event.tags?.third_party_code === 'true' - - // Drop third-party-only errors UNLESS they crashed the page via the global error boundary. - // This preserves noise reduction for browser extensions and injected scripts, - // while ensuring page-crashing errors from third-party libs (caused by first-party bugs) - // are always reported. - if (isThirdPartyOnly && !isErrorBoundaryCrash) { - return null - } - - // Downsample only known high-noise classes; keep all other errors at full rate. - const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes( - `Failed to construct 'URL': Invalid URL` - ) - const isSessionTimeoutEvent = (hint.originalException as any)?.message?.includes( - 'Session error detected' - ) - const isChunkLoadFailure = isChunkLoadError(hint.originalException, event) - - const codeSampleRate = - isInvalidUrlEvent || isSessionTimeoutEvent || isChunkLoadFailure - ? LOW_PRIORITY_ERROR_SAMPLE_RATE - : DEFAULT_ERROR_SAMPLE_RATE - - if (Math.random() > codeSampleRate) { - return null - } - - event.tags = { - ...event.tags, - codeSampleRate: codeSampleRate.toString(), - } - - if (isHCaptchaRelatedError(event)) { - return null - } - - // Drop events where every exception has no stack trace — these are not debuggable. - // Exempt error boundary crashes: even without stack frames, a page crash is always worth reporting. - const exceptions = event.exception?.values ?? [] - if ( - !isErrorBoundaryCrash && - exceptions.length > 0 && - exceptions.every((ex) => !ex.stacktrace?.frames?.length) - ) { - return null - } - - // Filter out errors like 'e._5BLbSXV[t] is not a function' or anything matching '[t] is not a function' - if ( - hint.originalException instanceof Error && - hint.originalException.message.includes('[t] is not a function') - ) { - return null - } - - if (isBrowserWalletExtensionError(event)) { - return null - } - if (isUserAbortedOperation(hint.originalException, event)) { - return null - } - if (isCancellationRejection(event)) { - return null - } - if (isChallengeExpiredError(hint.originalException, event)) { - return null - } - - if (event.breadcrumbs) { - event.breadcrumbs = sanitizeArrayOfObjects(event.breadcrumbs) as Sentry.Breadcrumb[] - } - return event - }, - ignoreErrors: [ - // === Monaco Editor === - 'ResizeObserver', - 's.getModifierState is not a function', - /^Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'/, - - // === Browser extension errors === - // Gate.io wallet - 'shouldSetTallyForCurrentProvider is not a function', - // SAP browser extensions (SAP GUI, SAP Companion) - 'sap is not defined', - // Non-Error objects thrown as exceptions (e.g., Event objects) - '[object Event]', - - // === Third-party SDK errors === - // stripe-js: https://github.com/stripe/stripe-js/issues/26 - 'Failed to load Stripe.js', - // hCaptcha - "undefined is not an object (evaluating 'n.chat.setReady')", - "undefined is not an object (evaluating 'i.chat.setReady')", - - // === Next.js internals === - // Ref: https://github.com/supabase/supabase/pull/9729 - /The provided `href` \(\/org\/\[slug\]\/.*\) value is missing query values/, - // Next.js throws these during navigation, not actual errors - 'NEXT_NOT_FOUND', - 'NEXT_REDIRECT', - - // === User input errors (not bugs) === - // sql-formatter lexer on invalid SQL input - /^Parse error: Unexpected ".+" at line \d+ column \d+$/, - - // === Network / infrastructure (not actionable on FE) === - /504 Gateway Time-out/, - 'Network request failed', - 'Failed to fetch', - 'Load failed', - 'AbortError', - 'TypeError: cancelled', - 'TypeError: Cancelled', - - // === Browser extensions & Google Translate DOM manipulation === - 'Node.insertBefore: Child to insert before is not a child of this node', - 'Node.removeChild: The node to be removed is not a child of this node', - "NotFoundError: Failed to execute 'removeChild' on 'Node'", - "NotFoundError: Failed to execute 'insertBefore' on 'Node'", - 'NotFoundError: The object can not be found here.', - "Cannot read properties of null (reading 'parentNode')", - "Cannot read properties of null (reading 'removeChild')", - "TypeError: can't access dead object", - /^NS_ERROR_/, - - // === Non-Error throws (extensions, third-party libs throwing strings/objects) === - 'Non-Error exception captured', - 'Non-Error promise rejection captured', - /^Object captured as exception with keys:/, - - // === Cross-origin script errors (no useful info) === - 'Script error.', - 'Script error', - - // === React hydration mismatches caused by extensions modifying DOM === - // Note: we only suppress the generic browser messages, NOT "Hydration failed because..." - // which can indicate real SSR/client mismatches in our own code. - /text content does not match/i, - /There was an error while hydrating/i, - - // === Web crawler / bot errors === - 'instantSearchSDKJSBridgeClearHighlight', - - // === Third-party library race conditions === - // cmdk: useSyncExternalStore subscribe called before store context is available - "Cannot read properties of undefined (reading 'subscribe')", - "undefined is not an object (evaluating 't.subscribe')", - - // === Misc known noise === - 'r.default.setDefaultLevel is not a function', - // Clipboard permission denied - 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.', - // Facebook pixel - 'fb_xd_fragment', - ], -}) +) // This export will instrument router navigations, and is only relevant if you enable tracing. export const onRouterTransitionStart = Sentry.captureRouterTransitionStart diff --git a/apps/studio/lib/external-identity-providers.ts b/apps/studio/lib/external-identity-providers.ts index 09bf0cef17d6a..a8ab1dcb5ede6 100644 --- a/apps/studio/lib/external-identity-providers.ts +++ b/apps/studio/lib/external-identity-providers.ts @@ -40,9 +40,22 @@ export const GITHUB_IDENTITY_PROVIDER: ExternalIdentityProviderConfig = { showInAccountPreferences: false, } +export const CHATGPT_IDENTITY_PROVIDER: ExternalIdentityProviderConfig = { + id: 'chatgpt', + authProvider: 'custom:openai', + displayName: 'ChatGPT', + iconPath: '/img/icons/openai-icon.svg', + showOnSignIn: true, + showOnSignUp: true, + showInAccountPreferences: false, +} + // Registry of every known provider, independent of which are currently enabled. Used for config and // display lookups (e.g. resolving the provider that a mid-flow interstitial was reached with). -const IDENTITY_PROVIDERS: ExternalIdentityProviderConfig[] = [GITHUB_IDENTITY_PROVIDER] +const IDENTITY_PROVIDERS: ExternalIdentityProviderConfig[] = [ + GITHUB_IDENTITY_PROVIDER, + CHATGPT_IDENTITY_PROVIDER, +] export function normalizeIconPath(iconPath: string): string { if ( diff --git a/apps/studio/instrumentation-client.test.ts b/apps/studio/lib/sentry-client-options.test.ts similarity index 77% rename from apps/studio/instrumentation-client.test.ts rename to apps/studio/lib/sentry-client-options.test.ts index c8f5d1aab13cc..74732c21557c3 100644 --- a/apps/studio/instrumentation-client.test.ts +++ b/apps/studio/lib/sentry-client-options.test.ts @@ -1,12 +1,13 @@ -import type { Event as SentryEvent, StackFrame } from '@sentry/nextjs' +import type { Event as SentryEvent, StackFrame } from '@sentry/react' import { describe, expect, it } from 'vitest' import { + buildSentryClientOptions, isBrowserWalletExtensionError, isCancellationRejection, isChallengeExpiredError, isUserAbortedOperation, -} from './instrumentation-client' +} from './sentry-client-options' describe('Sentry beforeSend filtering functions', () => { describe('isBrowserWalletExtensionError', () => { @@ -417,3 +418,80 @@ describe('Sentry beforeSend filtering functions', () => { }) }) }) + +describe('buildSentryClientOptions', () => { + // Representative subset of Sentry's default integrations. `integrations` + // is the function form: Sentry.init calls it with the defaults and installs + // whatever it returns, so dropping these here would disable session + // envelopes (BrowserSession) and window.onerror capture (GlobalHandlers). + const fakeDefaultIntegrations = [{ name: 'BrowserSession' }, { name: 'GlobalHandlers' }] + + const getIntegrationNames = (options: ReturnType) => { + const integrations = options.integrations + if (typeof integrations !== 'function') { + throw new Error('expected the function form of integrations') + } + return integrations(fakeDefaultIntegrations).map((integration) => integration.name) + } + + it('preserves the default integrations passed in by Sentry.init', () => { + for (const includeThirdPartyErrorFilter of [true, false]) { + const names = getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter })) + // browserSessionIntegration is what sends the session envelope on every + // page load; globalHandlers is window.onerror / unhandledrejection. + expect(names).toContain('BrowserSession') + expect(names).toContain('GlobalHandlers') + } + }) + + it('sets the release only when one is provided', () => { + const withRelease = buildSentryClientOptions({ + includeThirdPartyErrorFilter: false, + release: 'abc123', + }) + expect(withRelease.release).toBe('abc123') + + // The key must be ABSENT when no release is passed: on the Next build a + // `release: undefined` entry would override the release injected into + // @sentry/nextjs's init by withSentryConfig (options are spread last). + const withoutRelease = buildSentryClientOptions({ includeThirdPartyErrorFilter: true }) + expect('release' in withoutRelease).toBe(false) + }) + + it('includes the third-party error filter only when the build annotates frames', () => { + // Next build: withSentryConfig injects the applicationKey metadata. + expect( + getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter: true })) + ).toContain('ThirdPartyErrorsFilter') + + // TanStack/Vite build: no bundler metadata — including the integration + // would tag every event third_party_code=true and beforeSend would drop + // them all. + expect( + getIntegrationNames(buildSentryClientOptions({ includeThirdPartyErrorFilter: false })) + ).not.toContain('ThirdPartyErrorsFilter') + }) + + it('appends build-specific extra integrations', () => { + const options = buildSentryClientOptions({ + includeThirdPartyErrorFilter: false, + extraIntegrations: [{ name: 'FakeRouterTracing' }], + }) + + expect(getIntegrationNames(options)).toContain('FakeRouterTracing') + }) + + it('builds the same shared options for both builds (parity)', () => { + const nextOptions = buildSentryClientOptions({ includeThirdPartyErrorFilter: true }) + const tanstackOptions = buildSentryClientOptions({ includeThirdPartyErrorFilter: false }) + + // Everything except the integrations array must be identical between the + // two runtimes. + const { integrations: _next, ...nextRest } = nextOptions + const { integrations: _tanstack, ...tanstackRest } = tanstackOptions + expect(Object.keys(nextRest)).toEqual(Object.keys(tanstackRest)) + expect(nextRest.tracesSampleRate).toBe(tanstackRest.tracesSampleRate) + expect(nextRest.allowUrls).toEqual(tanstackRest.allowUrls) + expect(nextRest.ignoreErrors).toEqual(tanstackRest.ignoreErrors) + }) +}) diff --git a/apps/studio/lib/sentry-client-options.ts b/apps/studio/lib/sentry-client-options.ts new file mode 100644 index 0000000000000..693f01021dfae --- /dev/null +++ b/apps/studio/lib/sentry-client-options.ts @@ -0,0 +1,368 @@ +// Shared Sentry client-side configuration for BOTH Studio builds: +// +// - Next (pages router): `instrumentation-client.ts` — a Next convention +// file, auto-loaded by Next only — calls `Sentry.init` with these options. +// - TanStack Start (Vite): `sentry.tanstack.ts` calls `Sentry.init` +// with these options from `getRouter()` (router.tsx). TanStack Start does +// not load Next's convention files, so without its own init every +// `Sentry.captureException` there would be a silent no-op. +// +// Keep every shared option in this builder so the two runtimes cannot drift. +// +// `@sentry/react` is what `@sentry/nextjs` wraps on the client (same 10.x +// version, same module instance under pnpm), so building the options against +// it works for both `Sentry.init`s. +import * as Sentry from '@sentry/react' +import { thirdPartyErrorFilterIntegration } from '@sentry/react' +import { hasConsented } from 'common' +import { IS_PLATFORM } from 'common/constants/environment' + +import { MIRRORED_BREADCRUMBS } from '@/lib/breadcrumbs' +import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from '@/lib/sanitize' + +type Integration = Parameters[0] + +const DEFAULT_ERROR_SAMPLE_RATE = 1.0 +const LOW_PRIORITY_ERROR_SAMPLE_RATE = 0.01 +const CHUNK_LOAD_ERROR_PATTERNS = [ + /ChunkLoadError/i, + /Loading chunk [\d]+ failed/i, + /Loading CSS chunk [\d]+ failed/i, +] + +// This is a workaround to ignore hCaptcha related errors. +function isHCaptchaRelatedError(event: Sentry.Event): boolean { + const errors = event.exception?.values ?? [] + for (const error of errors) { + if ( + error.value?.includes('is not a function') && + error.stacktrace?.frames?.some((f) => f.filename === 'api.js') + ) { + return true + } + } + return false +} + +// Filter browser wallet extension errors (e.g., Gate.io wallet) +// These errors come from injected wallet scripts and are not actionable +// Examples: SUPABASE-APP-AFC, SUPABASE-APP-92A +export function isBrowserWalletExtensionError(event: Sentry.Event): boolean { + const frames = event.exception?.values?.flatMap((e) => e.stacktrace?.frames || []) || [] + return frames.some((frame) => { + const filename = frame.filename || frame.abs_path || '' + return filename.includes('gt-window-provider') || filename.includes('wallet-provider') + }) +} + +// Filter user-aborted operations (intentional cancellations) +// These are expected when users cancel requests or navigate away +// Examples: SUPABASE-APP-BG6, SUPABASE-APP-BG7 +export function isUserAbortedOperation(error: unknown, event: Sentry.Event): boolean { + const errorMessage = error instanceof Error ? error.message : '' + const eventMessage = event.message || '' + const message = errorMessage || eventMessage + + return ( + message.includes('operation was aborted') || + message.includes('signal is aborted') || + message.includes('manually canceled') || + message.includes('AbortError') + ) +} + +// Filter cancellation promise rejections (e.g., from query cancellation) +// These occur when operations are intentionally cancelled by the user +// Example: SUPABASE-APP-353 (~466k events) +export function isCancellationRejection(event: Sentry.Event): boolean { + const serialized = event.extra?.__serialized__ as Record | undefined + return serialized?.type === 'cancelation' +} + +// Filter challenge/captcha expired errors (user timeout) +// These happen when users don't complete captcha in time - expected behavior +// Example: SUPABASE-APP-ACC +export function isChallengeExpiredError(error: unknown, event: Sentry.Event): boolean { + const errorMessage = error instanceof Error ? error.message : '' + const eventMessage = event.message || '' + const message = errorMessage || eventMessage + + return message.includes('challenge-expired') +} + +function isChunkLoadError(error: unknown, event: Sentry.Event): boolean { + const errorMessage = error instanceof Error ? error.message : '' + const eventMessage = event.message || '' + const exceptionMessages = event.exception?.values?.map((ex) => ex.value ?? '') ?? [] + const combinedMessages = [errorMessage, eventMessage, ...exceptionMessages].filter(Boolean) + + return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => + combinedMessages.some((message) => pattern.test(message)) + ) +} + +// Tag errors whose stack trace only contains third-party frames (browser extensions, +// injected scripts, etc.). This uses build-time code annotation via the applicationKey +// in next.config.ts to reliably distinguish our code from third-party code. +// We use 'apply-tag' instead of 'drop' so that beforeSend can exempt error boundary +// crashes — these may originate in third-party code but are caused by first-party bugs. +function buildThirdPartyErrorFilterIntegration(): Integration { + return thirdPartyErrorFilterIntegration({ + filterKeys: ['supabase-studio'], + behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', + }) +} + +export interface SentryClientOptionsParams { + /** + * Whether to include `thirdPartyErrorFilterIntegration`. + * + * Only enable this on builds whose bundler annotates stack frames with the + * `supabase-studio` applicationKey (the Next build does, via + * `withSentryConfig` in next.config.ts). On a build WITHOUT the annotation + * no frame carries first-party metadata, so the integration tags EVERY + * event `third_party_code: true` and `beforeSend` would then drop all + * non-error-boundary events. + */ + includeThirdPartyErrorFilter: boolean + /** Build-specific integrations (e.g. TanStack Router browser tracing). */ + extraIntegrations?: Integration[] + /** + * Release identifier for the client. + * + * The SDK SILENTLY DROPS session envelopes when the client has no release + * (`Client.sendSession` early-returns), so a build without a release sends + * no Release Health traffic at all — errors and traces still flow. + * + * The Next build must NOT pass this: `withSentryConfig` injects the release + * (`SENTRY_RELEASE` ?? the Vercel commit SHA) into the bundle at build time, + * and an explicit `release` key — even `undefined` — would override it. + * The TanStack/Vite build runs no Sentry bundler plugin, so it passes the + * commit SHA here instead (see sentry.tanstack.ts). + */ + release?: string +} + +export function buildSentryClientOptions({ + includeThirdPartyErrorFilter, + extraIntegrations = [], + release, +}: SentryClientOptionsParams): Sentry.BrowserOptions { + return { + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + ...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && { + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, + }), + // Conditional spread: see the `release` doc comment above — the key must + // be ABSENT (not `undefined`) so the Next build's injected release wins. + ...(release && { release }), + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + // Enable performance monitoring + tracesSampleRate: 0.02, + + // Function form so Sentry's default integrations (browserSession, + // globalHandlers, breadcrumbs, dedupe, …) are explicitly preserved — this + // is the documented way to extend the defaults, and it can never be + // misread as replacing them. + integrations: (defaultIntegrations) => [ + ...defaultIntegrations, + ...(includeThirdPartyErrorFilter ? [buildThirdPartyErrorFilterIntegration()] : []), + ...extraIntegrations, + ], + + // Only capture errors originating from our own code. + // This is a whitelist on the source URL in stack frames — it drops errors from + // browser extensions, injected scripts, third-party widgets, etc. (FE-2094) + allowUrls: [ + /https?:\/\/(.*\.)?supabase\.(com|co|green|io)/, + /app:\/\//, // Next.js rewrites source URLs to app:// with source maps + ], + beforeBreadcrumb(breadcrumb, _hint) { + const cleanedBreadcrumb = { ...breadcrumb } + + if (cleanedBreadcrumb.category === 'navigation') { + if (typeof cleanedBreadcrumb.data?.from === 'string') { + cleanedBreadcrumb.data.from = sanitizeUrlHashParams(cleanedBreadcrumb.data.from) + } + if (typeof cleanedBreadcrumb.data?.to === 'string') { + cleanedBreadcrumb.data.to = sanitizeUrlHashParams(cleanedBreadcrumb.data.to) + } + } + + MIRRORED_BREADCRUMBS.pushBack(cleanedBreadcrumb) + return cleanedBreadcrumb + }, + beforeSend(event, hint) { + const consent = hasConsented() + + if (!consent) { + return null + } + + if (!IS_PLATFORM) { + return null + } + + const isErrorBoundaryCrash = + event.tags?.globalErrorBoundary === true || event.tags?.globalErrorBoundary === 'true' + const isThirdPartyOnly = + event.tags?.third_party_code === true || event.tags?.third_party_code === 'true' + + // Drop third-party-only errors UNLESS they crashed the page via the global error boundary. + // This preserves noise reduction for browser extensions and injected scripts, + // while ensuring page-crashing errors from third-party libs (caused by first-party bugs) + // are always reported. + if (isThirdPartyOnly && !isErrorBoundaryCrash) { + return null + } + + // Downsample only known high-noise classes; keep all other errors at full rate. + const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes( + `Failed to construct 'URL': Invalid URL` + ) + const isSessionTimeoutEvent = (hint.originalException as any)?.message?.includes( + 'Session error detected' + ) + const isChunkLoadFailure = isChunkLoadError(hint.originalException, event) + + const codeSampleRate = + isInvalidUrlEvent || isSessionTimeoutEvent || isChunkLoadFailure + ? LOW_PRIORITY_ERROR_SAMPLE_RATE + : DEFAULT_ERROR_SAMPLE_RATE + + if (Math.random() > codeSampleRate) { + return null + } + + event.tags = { + ...event.tags, + codeSampleRate: codeSampleRate.toString(), + } + + if (isHCaptchaRelatedError(event)) { + return null + } + + // Drop events where every exception has no stack trace — these are not debuggable. + // Exempt error boundary crashes: even without stack frames, a page crash is always worth reporting. + const exceptions = event.exception?.values ?? [] + if ( + !isErrorBoundaryCrash && + exceptions.length > 0 && + exceptions.every((ex) => !ex.stacktrace?.frames?.length) + ) { + return null + } + + // Filter out errors like 'e._5BLbSXV[t] is not a function' or anything matching '[t] is not a function' + if ( + hint.originalException instanceof Error && + hint.originalException.message.includes('[t] is not a function') + ) { + return null + } + + if (isBrowserWalletExtensionError(event)) { + return null + } + if (isUserAbortedOperation(hint.originalException, event)) { + return null + } + if (isCancellationRejection(event)) { + return null + } + if (isChallengeExpiredError(hint.originalException, event)) { + return null + } + + if (event.breadcrumbs) { + event.breadcrumbs = sanitizeArrayOfObjects(event.breadcrumbs) as Sentry.Breadcrumb[] + } + return event + }, + ignoreErrors: [ + // === Monaco Editor === + 'ResizeObserver', + 's.getModifierState is not a function', + /^Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'/, + + // === Browser extension errors === + // Gate.io wallet + 'shouldSetTallyForCurrentProvider is not a function', + // SAP browser extensions (SAP GUI, SAP Companion) + 'sap is not defined', + // Non-Error objects thrown as exceptions (e.g., Event objects) + '[object Event]', + + // === Third-party SDK errors === + // stripe-js: https://github.com/stripe/stripe-js/issues/26 + 'Failed to load Stripe.js', + // hCaptcha + "undefined is not an object (evaluating 'n.chat.setReady')", + "undefined is not an object (evaluating 'i.chat.setReady')", + + // === Next.js internals === + // Ref: https://github.com/supabase/supabase/pull/9729 + /The provided `href` \(\/org\/\[slug\]\/.*\) value is missing query values/, + // Next.js throws these during navigation, not actual errors + 'NEXT_NOT_FOUND', + 'NEXT_REDIRECT', + + // === User input errors (not bugs) === + // sql-formatter lexer on invalid SQL input + /^Parse error: Unexpected ".+" at line \d+ column \d+$/, + + // === Network / infrastructure (not actionable on FE) === + /504 Gateway Time-out/, + 'Network request failed', + 'Failed to fetch', + 'Load failed', + 'AbortError', + 'TypeError: cancelled', + 'TypeError: Cancelled', + + // === Browser extensions & Google Translate DOM manipulation === + 'Node.insertBefore: Child to insert before is not a child of this node', + 'Node.removeChild: The node to be removed is not a child of this node', + "NotFoundError: Failed to execute 'removeChild' on 'Node'", + "NotFoundError: Failed to execute 'insertBefore' on 'Node'", + 'NotFoundError: The object can not be found here.', + "Cannot read properties of null (reading 'parentNode')", + "Cannot read properties of null (reading 'removeChild')", + "TypeError: can't access dead object", + /^NS_ERROR_/, + + // === Non-Error throws (extensions, third-party libs throwing strings/objects) === + 'Non-Error exception captured', + 'Non-Error promise rejection captured', + /^Object captured as exception with keys:/, + + // === Cross-origin script errors (no useful info) === + 'Script error.', + 'Script error', + + // === React hydration mismatches caused by extensions modifying DOM === + // Note: we only suppress the generic browser messages, NOT "Hydration failed because..." + // which can indicate real SSR/client mismatches in our own code. + /text content does not match/i, + /There was an error while hydrating/i, + + // === Web crawler / bot errors === + 'instantSearchSDKJSBridgeClearHighlight', + + // === Third-party library race conditions === + // cmdk: useSyncExternalStore subscribe called before store context is available + "Cannot read properties of undefined (reading 'subscribe')", + "undefined is not an object (evaluating 't.subscribe')", + + // === Misc known noise === + 'r.default.setDefaultLevel is not a function', + // Clipboard permission denied + 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.', + // Facebook pixel + 'fb_xd_fragment', + ], + } +} diff --git a/apps/studio/package.json b/apps/studio/package.json index 6eaf32965b7be..78122739a90e0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -209,6 +209,7 @@ "require-in-the-middle": "^8.0.0", "tsconfig": "workspace:*", "tsx": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-tsconfig-paths": "catalog:", 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 0000000000000..f7acbf46ba87c --- /dev/null +++ b/apps/studio/public/img/icons/openai-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/studio/router.tsx b/apps/studio/router.tsx index 35a24884b768e..81f101d4c91d8 100644 --- a/apps/studio/router.tsx +++ b/apps/studio/router.tsx @@ -3,6 +3,7 @@ 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' @@ -87,6 +88,12 @@ export function getRouter() { 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/sentry.tanstack.test.ts b/apps/studio/sentry.tanstack.test.ts new file mode 100644 index 0000000000000..2adb968ef6aba --- /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 0000000000000..075198c390467 --- /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/sql-editor/sql-editor-lifecycle.ts b/apps/studio/state/sql-editor/sql-editor-lifecycle.ts index 190231b1fb73c..8502b51c805e9 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 a582e91753f9d..b591947782779 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 52e79e2252da7..6e1c6cdc5458d 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/tabs.test.ts b/apps/studio/state/tabs.test.ts index 0bd4d81494e2e..9cbfa766a10d3 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() @@ -127,3 +138,132 @@ describe('tabs removal', () => { 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 310e6a5c30ac0..2b72256579bb5 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, @@ -328,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, @@ -399,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/tsconfig.json b/apps/studio/tsconfig.json index 5bf688391c280..ab88b97192411 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/vite.config.ts b/apps/studio/vite.config.ts index 23979bed19b68..60849d6f3d05f 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -372,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] @@ -380,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): diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json index 4d35dc19cdcd3..844cd637bb43c 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/www/_customers/brevo.mdx b/apps/www/_customers/brevo.mdx index 71334f2e36270..f8eaf7cd6706f 100644 --- a/apps/www/_customers/brevo.mdx +++ b/apps/www/_customers/brevo.mdx @@ -10,8 +10,8 @@ author: prashant author_title: Prashant Sridharan author_url: https://github.com/CoolAssPuppy author_image_url: https://avatars.githubusercontent.com/u/914007?v=4 -logo: /images/customers/logos/brevo.svg -logo_inverse: /images/customers/logos/light/brevo.svg +logo: /images/customers/logos/brevo.png +logo_inverse: /images/customers/logos/light/brevo.png tags: - supabase date: '2025-12-18' diff --git a/apps/www/_customers/delightai.mdx b/apps/www/_customers/delightai.mdx index 98b8711d8b59b..bbf5a0c6ddb61 100644 --- a/apps/www/_customers/delightai.mdx +++ b/apps/www/_customers/delightai.mdx @@ -10,8 +10,8 @@ author: prashant author_title: Prashant Sridharan author_url: https://github.com/CoolAssPuppy author_image_url: https://avatars.githubusercontent.com/u/914007?v=4 -logo: /images/customers/logos/delightai.svg -logo_inverse: /images/customers/logos/light/delightai.svg +logo: /images/customers/logos/delightai.png +logo_inverse: /images/customers/logos/light/delightai.png tags: - supabase date: '2026-07-08' diff --git a/apps/www/_customers/drew-crew.mdx b/apps/www/_customers/drew-crew.mdx index d57afd5633dba..58bdeb8e73bca 100644 --- a/apps/www/_customers/drew-crew.mdx +++ b/apps/www/_customers/drew-crew.mdx @@ -10,8 +10,8 @@ author: prashant author_title: Prashant Sridharan author_url: https://github.com/CoolAssPuppy author_image_url: https://avatars.githubusercontent.com/u/914007?v=4 -logo: /images/customers/logos/drew-crew.svg -logo_inverse: /images/customers/logos/light/drew-crew.svg +logo: /images/customers/logos/drew-crew.png +logo_inverse: /images/customers/logos/light/drew-crew.png tags: - supabase date: '2026-07-08' diff --git a/apps/www/_customers/exprealty.mdx b/apps/www/_customers/exprealty.mdx index 395dd8744aaee..3ae2f08f410e8 100644 --- a/apps/www/_customers/exprealty.mdx +++ b/apps/www/_customers/exprealty.mdx @@ -7,8 +7,8 @@ author: prashant author_title: Prashant Sridharan author_url: https://github.com/CoolAssPuppy author_image_url: https://avatars.githubusercontent.com/u/914007?v=4 -logo: /images/customers/logos/exprealty.svg -logo_inverse: /images/customers/logos/light/exprealty.svg +logo: /images/customers/logos/exprealty.png +logo_inverse: /images/customers/logos/light/exprealty.png tags: - supabase date: '2025-12-17' diff --git a/apps/www/_customers/govsignals.mdx b/apps/www/_customers/govsignals.mdx index f485e7a70335d..9bc6a5e178fec 100644 --- a/apps/www/_customers/govsignals.mdx +++ b/apps/www/_customers/govsignals.mdx @@ -7,8 +7,8 @@ author: prashant author_title: Prashant Sridharan author_url: https://github.com/CoolAssPuppy author_image_url: https://avatars.githubusercontent.com/u/914007?v=4 -logo: /images/customers/logos/govsignals.svg -logo_inverse: /images/customers/logos/light/govsignals.svg +logo: /images/customers/logos/govsignals.png +logo_inverse: /images/customers/logos/light/govsignals.png tags: - supabase date: '2025-12-16' diff --git a/apps/www/_customers/hyper.mdx b/apps/www/_customers/hyper.mdx index 30907d1356c2d..df2aa93d61b91 100644 --- a/apps/www/_customers/hyper.mdx +++ b/apps/www/_customers/hyper.mdx @@ -10,8 +10,8 @@ author: prashant author_title: Prashant Sridharan author_url: https://github.com/CoolAssPuppy author_image_url: https://avatars.githubusercontent.com/u/914007?v=4 -logo: /images/customers/logos/hyper.svg -logo_inverse: /images/customers/logos/light/hyper.svg +logo: /images/customers/logos/hyper.png +logo_inverse: /images/customers/logos/light/hyper.png tags: - supabase date: '2025-12-19' diff --git a/apps/www/_customers/koodos.mdx b/apps/www/_customers/koodos.mdx index 92e725d4d1aed..bc57b9d1228a6 100644 --- a/apps/www/_customers/koodos.mdx +++ b/apps/www/_customers/koodos.mdx @@ -7,8 +7,8 @@ author: prashant author_title: Prashant Sridharan author_url: https://github.com/CoolAssPuppy author_image_url: https://avatars.githubusercontent.com/u/914007?v=4 -logo: /images/customers/logos/koodos.svg -logo_inverse: /images/customers/logos/light/koodos.svg +logo: /images/customers/logos/koodos.png +logo_inverse: /images/customers/logos/light/koodos.png tags: - supabase date: '2025-12-16' diff --git a/apps/www/_customers/lovable.mdx b/apps/www/_customers/lovable.mdx index 1e7456abd7183..77ca5a3ae4ead 100644 --- a/apps/www/_customers/lovable.mdx +++ b/apps/www/_customers/lovable.mdx @@ -10,8 +10,8 @@ author: prashant author_title: Prashant Sridharan author_url: https://github.com/CoolAssPuppy author_image_url: https://avatars.githubusercontent.com/u/914007?v=4 -logo: /images/customers/logos/lovable.svg -logo_inverse: /images/customers/logos/light/lovable.svg +logo: /images/customers/logos/lovable.png +logo_inverse: /images/customers/logos/light/lovable.png tags: - supabase date: '2026-07-09' diff --git a/apps/www/app/(home)/_components/CustomerStoriesSection.tsx b/apps/www/app/(home)/_components/CustomerStoriesSection.tsx index 380ae364eb30e..ee59c66c1b9df 100644 --- a/apps/www/app/(home)/_components/CustomerStoriesSection.tsx +++ b/apps/www/app/(home)/_components/CustomerStoriesSection.tsx @@ -45,7 +45,7 @@ const customerStories = [ }, { name: 'Hyper', - logo: '/images/customers/logos/hyper.svg', + logo: '/images/customers/logos/hyper.png', icon: '/images/customers/logos/hyper-icon.svg', tagline: 'An AI-native marketing platform with agents that operate across the entire workflow.', quote: diff --git a/apps/www/components/CodeBlock/CodeBlock.tsx b/apps/www/components/CodeBlock/CodeBlock.tsx index 2e8e8f35d437b..f0d9a8da4537c 100644 --- a/apps/www/components/CodeBlock/CodeBlock.tsx +++ b/apps/www/components/CodeBlock/CodeBlock.tsx @@ -2,7 +2,7 @@ import { Check, Copy, File, Terminal } from 'lucide-react' import { useTheme } from 'next-themes' -import { useEffect, useState } from 'react' +import { useEffect, useState, type CSSProperties } from 'react' import CopyToClipboard from 'react-copy-to-clipboard' import { Light as SyntaxHighlighter } from 'react-syntax-highlighter' import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash' @@ -114,17 +114,16 @@ function CodeBlock(props: CodeBlockProps) {
)}
- {/* @ts-ignore */} } className={cn( 'synthax-highlighter border border-default/15 rounded-lg', @@ -152,7 +151,7 @@ function CodeBlock(props: CodeBlockProps) { fontSize: large ? 14 : '0.75rem', }} > - {content} + {content ?? ''} {!props.hideCopy && props.children ? (
diff --git a/apps/www/data/CustomerStories.ts b/apps/www/data/CustomerStories.ts index 0ee0859bcfd98..83bff407abab3 100644 --- a/apps/www/data/CustomerStories.ts +++ b/apps/www/data/CustomerStories.ts @@ -36,9 +36,9 @@ export const data: CustomerStoryType[] = [ description: 'Hyper is an AI-native marketing platform with agents that operate across the entire marketing workflow. Supabase gives their three-person team the database platform to do it at enterprise scale.', organization: 'Hyper', - imgUrl: 'images/customers/logos/hyper.svg', - logo: '/images/customers/logos/hyper.svg', - logo_inverse: '/images/customers/logos/light/hyper.svg', + imgUrl: 'images/customers/logos/hyper.png', + logo: '/images/customers/logos/hyper.png', + logo_inverse: '/images/customers/logos/light/hyper.png', url: '/customers/hyper', ctaText: 'View story', }, diff --git a/apps/www/data/solutions/agents.tsx b/apps/www/data/solutions/agents.tsx index 8b4dcd2651e40..60684209e2931 100644 --- a/apps/www/data/solutions/agents.tsx +++ b/apps/www/data/solutions/agents.tsx @@ -320,7 +320,7 @@ const data: () => { customers: [ { name: 'Hyper', - logo: '/images/customers/logos/hyper.svg', + logo: '/images/customers/logos/hyper.png', highlights: [ '100x cost reduction by having agents query SQL instead of live platform APIs', 'Per-customer isolated databases for agencies managing hundreds of clients', diff --git a/apps/www/data/solutions/innovation-teams.tsx b/apps/www/data/solutions/innovation-teams.tsx index a6a7a12627489..72a46f18adf50 100644 --- a/apps/www/data/solutions/innovation-teams.tsx +++ b/apps/www/data/solutions/innovation-teams.tsx @@ -276,7 +276,7 @@ const data: () => { customers: [ { name: 'eXp Realty', - logo: '/images/customers/logos/exprealty.svg', + logo: '/images/customers/logos/exprealty.png', highlights: [ 'Saved $3M+ annually across multiple systems', '70+ vibe-coded applications in production', diff --git a/apps/www/package.json b/apps/www/package.json index 73c53727667e4..5495f58a98eee 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -90,6 +90,7 @@ "shiki": "^4.2.0", "swiper": "^12.1.2", "typed.js": "^2.0.16", + "@typescript/native": "catalog:", "typescript": "catalog:", "ui": "workspace:*", "ui-patterns": "workspace:*", diff --git a/apps/www/public/customers-rss.xml b/apps/www/public/customers-rss.xml index 650288d842263..727601c6bd207 100644 --- a/apps/www/public/customers-rss.xml +++ b/apps/www/public/customers-rss.xml @@ -5,9 +5,16 @@ https://supabase.com Latest news from Supabase en - Wed, 08 Jul 2026 00:00:00 -0700 + Thu, 09 Jul 2026 00:00:00 -0700 + https://supabase.com/customers/lovable + How Supabase helps power millions of apps on Lovable + https://supabase.com/customers/lovable + Lovable lets anyone create software by describing it. Behind many of the one million projects people create on Lovable every week is a Supabase backend, provisioned end to end through the Management API. + Thu, 09 Jul 2026 00:00:00 -0700 + + https://supabase.com/customers/delightai How delight.ai lets every team build production apps on Supabase https://supabase.com/customers/delightai @@ -21,13 +28,6 @@ Drew Clayborn is building General Home Health, a HIPAA-compliant home care charting app, by himself. Supabase gives a solo developer the database, auth, realtime, and HIPAA-ready foundation to protect patients, nurses, and agencies. Wed, 08 Jul 2026 00:00:00 -0700 - - https://supabase.com/customers/lovable - How Supabase helps power millions of apps on Lovable - https://supabase.com/customers/lovable - Lovable lets anyone create software by describing it. Behind many of the one million projects people create on Lovable every week is a Supabase backend, provisioned end to end through the Management API. - Wed, 08 Jul 2026 00:00:00 -0700 - https://supabase.com/customers/chatbase Chatbase goes upmarket on Supabase diff --git a/apps/www/public/images/customers/logos/brevo.png b/apps/www/public/images/customers/logos/brevo.png new file mode 100644 index 0000000000000..8c10af4d5959b Binary files /dev/null and b/apps/www/public/images/customers/logos/brevo.png differ diff --git a/apps/www/public/images/customers/logos/brevo.svg b/apps/www/public/images/customers/logos/brevo.svg deleted file mode 100644 index d65fff180e40a..0000000000000 --- a/apps/www/public/images/customers/logos/brevo.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/www/public/images/customers/logos/cofounder.png b/apps/www/public/images/customers/logos/cofounder.png index 051497f45a539..86f07585383c4 100644 Binary files a/apps/www/public/images/customers/logos/cofounder.png and b/apps/www/public/images/customers/logos/cofounder.png differ diff --git a/apps/www/public/images/customers/logos/delightai.png b/apps/www/public/images/customers/logos/delightai.png new file mode 100644 index 0000000000000..3e2bdc47661cc Binary files /dev/null and b/apps/www/public/images/customers/logos/delightai.png differ diff --git a/apps/www/public/images/customers/logos/delightai.svg b/apps/www/public/images/customers/logos/delightai.svg deleted file mode 100644 index bcca354fb8555..0000000000000 --- a/apps/www/public/images/customers/logos/delightai.svg +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/apps/www/public/images/customers/logos/drew-crew.png b/apps/www/public/images/customers/logos/drew-crew.png new file mode 100644 index 0000000000000..cfa568fcbab6a Binary files /dev/null and b/apps/www/public/images/customers/logos/drew-crew.png differ diff --git a/apps/www/public/images/customers/logos/drew-crew.svg b/apps/www/public/images/customers/logos/drew-crew.svg deleted file mode 100644 index 35e19c7c29dc3..0000000000000 --- a/apps/www/public/images/customers/logos/drew-crew.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/www/public/images/customers/logos/e2b-icon.svg b/apps/www/public/images/customers/logos/e2b-icon.svg index 13ef7282fe12b..c12ef1beec164 100644 --- a/apps/www/public/images/customers/logos/e2b-icon.svg +++ b/apps/www/public/images/customers/logos/e2b-icon.svg @@ -1,3 +1,10 @@ - - + + + + + + + + + diff --git a/apps/www/public/images/customers/logos/e2b.png b/apps/www/public/images/customers/logos/e2b.png index ec56fb1b4ae20..b9ac1672e0c0c 100644 Binary files a/apps/www/public/images/customers/logos/e2b.png and b/apps/www/public/images/customers/logos/e2b.png differ diff --git a/apps/www/public/images/customers/logos/exprealty.png b/apps/www/public/images/customers/logos/exprealty.png new file mode 100644 index 0000000000000..94d6409b93d29 Binary files /dev/null and b/apps/www/public/images/customers/logos/exprealty.png differ diff --git a/apps/www/public/images/customers/logos/exprealty.svg b/apps/www/public/images/customers/logos/exprealty.svg deleted file mode 100644 index d24eccffdd492..0000000000000 --- a/apps/www/public/images/customers/logos/exprealty.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/www/public/images/customers/logos/govsignals.png b/apps/www/public/images/customers/logos/govsignals.png new file mode 100644 index 0000000000000..a500d53f34186 Binary files /dev/null and b/apps/www/public/images/customers/logos/govsignals.png differ diff --git a/apps/www/public/images/customers/logos/govsignals.svg b/apps/www/public/images/customers/logos/govsignals.svg deleted file mode 100644 index 3f6e0a96fc21f..0000000000000 --- a/apps/www/public/images/customers/logos/govsignals.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/apps/www/public/images/customers/logos/hyper-icon.svg b/apps/www/public/images/customers/logos/hyper-icon.svg index 790af453eec8d..ce4ce3852348d 100644 --- a/apps/www/public/images/customers/logos/hyper-icon.svg +++ b/apps/www/public/images/customers/logos/hyper-icon.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/apps/www/public/images/customers/logos/hyper.png b/apps/www/public/images/customers/logos/hyper.png new file mode 100644 index 0000000000000..b8eb1664a4550 Binary files /dev/null and b/apps/www/public/images/customers/logos/hyper.png differ diff --git a/apps/www/public/images/customers/logos/hyper.svg b/apps/www/public/images/customers/logos/hyper.svg deleted file mode 100644 index 9da486d9b4861..0000000000000 --- a/apps/www/public/images/customers/logos/hyper.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/www/public/images/customers/logos/koodos.png b/apps/www/public/images/customers/logos/koodos.png new file mode 100644 index 0000000000000..7233c7293e170 Binary files /dev/null and b/apps/www/public/images/customers/logos/koodos.png differ diff --git a/apps/www/public/images/customers/logos/koodos.svg b/apps/www/public/images/customers/logos/koodos.svg deleted file mode 100644 index 7c707ba474416..0000000000000 --- a/apps/www/public/images/customers/logos/koodos.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - -Created by potrace 1.16, written by Peter Selinger 2001-2019 - - - - - - - - - - diff --git a/apps/www/public/images/customers/logos/light/brevo.png b/apps/www/public/images/customers/logos/light/brevo.png new file mode 100644 index 0000000000000..9ec795cfd1012 Binary files /dev/null and b/apps/www/public/images/customers/logos/light/brevo.png differ diff --git a/apps/www/public/images/customers/logos/light/brevo.svg b/apps/www/public/images/customers/logos/light/brevo.svg deleted file mode 100644 index d369d572afd27..0000000000000 --- a/apps/www/public/images/customers/logos/light/brevo.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/www/public/images/customers/logos/light/cofounder.png b/apps/www/public/images/customers/logos/light/cofounder.png index 976279e6fdbad..c24c8d8ba3b00 100644 Binary files a/apps/www/public/images/customers/logos/light/cofounder.png and b/apps/www/public/images/customers/logos/light/cofounder.png differ diff --git a/apps/www/public/images/customers/logos/light/delightai.png b/apps/www/public/images/customers/logos/light/delightai.png new file mode 100644 index 0000000000000..fea40c063f51f Binary files /dev/null and b/apps/www/public/images/customers/logos/light/delightai.png differ diff --git a/apps/www/public/images/customers/logos/light/delightai.svg b/apps/www/public/images/customers/logos/light/delightai.svg deleted file mode 100644 index 94fe4042b14fc..0000000000000 --- a/apps/www/public/images/customers/logos/light/delightai.svg +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/apps/www/public/images/customers/logos/light/drew-crew.png b/apps/www/public/images/customers/logos/light/drew-crew.png new file mode 100644 index 0000000000000..9c8fe3525890d Binary files /dev/null and b/apps/www/public/images/customers/logos/light/drew-crew.png differ diff --git a/apps/www/public/images/customers/logos/light/drew-crew.svg b/apps/www/public/images/customers/logos/light/drew-crew.svg deleted file mode 100644 index f655fecd0fb8a..0000000000000 --- a/apps/www/public/images/customers/logos/light/drew-crew.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/www/public/images/customers/logos/light/exprealty.png b/apps/www/public/images/customers/logos/light/exprealty.png new file mode 100644 index 0000000000000..bf394c56c2987 Binary files /dev/null and b/apps/www/public/images/customers/logos/light/exprealty.png differ diff --git a/apps/www/public/images/customers/logos/light/exprealty.svg b/apps/www/public/images/customers/logos/light/exprealty.svg deleted file mode 100644 index a615def52f8b0..0000000000000 --- a/apps/www/public/images/customers/logos/light/exprealty.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/www/public/images/customers/logos/light/govsignals.png b/apps/www/public/images/customers/logos/light/govsignals.png new file mode 100644 index 0000000000000..60c53cf9c0520 Binary files /dev/null and b/apps/www/public/images/customers/logos/light/govsignals.png differ diff --git a/apps/www/public/images/customers/logos/light/govsignals.svg b/apps/www/public/images/customers/logos/light/govsignals.svg deleted file mode 100644 index 295e72283b286..0000000000000 --- a/apps/www/public/images/customers/logos/light/govsignals.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/apps/www/public/images/customers/logos/light/hyper.png b/apps/www/public/images/customers/logos/light/hyper.png new file mode 100644 index 0000000000000..624f4e7144be4 Binary files /dev/null and b/apps/www/public/images/customers/logos/light/hyper.png differ diff --git a/apps/www/public/images/customers/logos/light/koodos.png b/apps/www/public/images/customers/logos/light/koodos.png new file mode 100644 index 0000000000000..9a728675a9a94 Binary files /dev/null and b/apps/www/public/images/customers/logos/light/koodos.png differ diff --git a/apps/www/public/images/customers/logos/light/koodos.svg b/apps/www/public/images/customers/logos/light/koodos.svg deleted file mode 100644 index 25dc89993e7fb..0000000000000 --- a/apps/www/public/images/customers/logos/light/koodos.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - -Created by potrace 1.16, written by Peter Selinger 2001-2019 - - - - - - - - - - diff --git a/apps/www/public/images/customers/logos/light/lovable.png b/apps/www/public/images/customers/logos/light/lovable.png new file mode 100644 index 0000000000000..7e1236e9aa38f Binary files /dev/null and b/apps/www/public/images/customers/logos/light/lovable.png differ diff --git a/apps/www/public/images/customers/logos/light/lovable.svg b/apps/www/public/images/customers/logos/light/lovable.svg deleted file mode 100644 index 28ee97ae6e4cb..0000000000000 --- a/apps/www/public/images/customers/logos/light/lovable.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/www/public/images/customers/logos/light/meshy.png b/apps/www/public/images/customers/logos/light/meshy.png index af65bdace323d..9fd99e0ed2768 100644 Binary files a/apps/www/public/images/customers/logos/light/meshy.png and b/apps/www/public/images/customers/logos/light/meshy.png differ diff --git a/apps/www/public/images/customers/logos/light/rally.png b/apps/www/public/images/customers/logos/light/rally.png index 8c28334851fab..81b3e4c9a073c 100644 Binary files a/apps/www/public/images/customers/logos/light/rally.png and b/apps/www/public/images/customers/logos/light/rally.png differ diff --git a/apps/www/public/images/customers/logos/lovable.png b/apps/www/public/images/customers/logos/lovable.png new file mode 100644 index 0000000000000..c491ac1505e13 Binary files /dev/null and b/apps/www/public/images/customers/logos/lovable.png differ diff --git a/apps/www/public/images/customers/logos/lovable.svg b/apps/www/public/images/customers/logos/lovable.svg deleted file mode 100644 index cbe26cd04420b..0000000000000 --- a/apps/www/public/images/customers/logos/lovable.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/www/public/images/customers/logos/meshy.png b/apps/www/public/images/customers/logos/meshy.png index 1cdfb2918b2a8..524502ef1df0a 100644 Binary files a/apps/www/public/images/customers/logos/meshy.png and b/apps/www/public/images/customers/logos/meshy.png differ diff --git a/apps/www/public/images/customers/logos/rally.png b/apps/www/public/images/customers/logos/rally.png index e4203e3dc3104..0591123d62ac4 100644 Binary files a/apps/www/public/images/customers/logos/rally.png and b/apps/www/public/images/customers/logos/rally.png differ diff --git a/blocks/vue/package.json b/blocks/vue/package.json index 7ac1bcd10d163..8ff4abe17ccea 100644 --- a/blocks/vue/package.json +++ b/blocks/vue/package.json @@ -26,6 +26,8 @@ "devDependencies": { "shadcn": "^3.3.1", "tsconfig": "workspace:*", + "@typescript/native": "catalog:", + "typescript": "catalog:", "vite": "^7.3.2" } } diff --git a/package.json b/package.json index ff71db442bf3e..4b5c8e45eae81 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "tailwindcss": "catalog:", "tsx": "catalog:", "turbo": "2.9.14", + "@typescript/native": "catalog:", "typescript": "catalog:", "zod": "catalog:" }, diff --git a/packages/ai-commands/package.json b/packages/ai-commands/package.json index bde2074adca7c..68b2181eaccae 100644 --- a/packages/ai-commands/package.json +++ b/packages/ai-commands/package.json @@ -34,6 +34,7 @@ "mdast-util-from-markdown": "^2.0.0", "sql-formatter": "^15.0.0", "tsconfig": "workspace:*", + "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/packages/api-types/package.json b/packages/api-types/package.json index 6674869602be4..666602c98a743 100644 --- a/packages/api-types/package.json +++ b/packages/api-types/package.json @@ -14,6 +14,7 @@ "devDependencies": { "openapi-typescript": "^7.4.3", "prettier": "*", + "@typescript/native": "catalog:", "typescript": "catalog:" } } diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index b31810f31eb0c..0968d6892df99 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -26,9 +26,11 @@ export const LOCAL_STORAGE_KEYS = { UI_PREVIEW_PLATFORM_WEBHOOKS: 'supabase-ui-platform-webhooks', UI_PREVIEW_JIT_DB_ACCESS: 'supabase-ui-jit-db-access', UI_PREVIEW_RLS_TESTER: 'supabase-ui-rls-tester', + UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE: 'supabase-ui-sql-editor-manual-save', UI_PREVIEW_MARKETPLACE: 'supabase-ui-marketplace', AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in', + SIGN_IN_CHATGPT_ENABLED: 'siwc-enabled', DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`, STORAGE_PREFERENCE: (ref: string) => `storage-explorer-${ref}`, @@ -154,11 +156,13 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [ LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS, LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS, + LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE, LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE, LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD, LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST, LOCAL_STORAGE_KEYS.BLOG_VIEW, LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN, + LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED, LOCAL_STORAGE_KEYS.LINTER_SHOW_FOOTER, LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR, LOCAL_STORAGE_KEYS.UI_TIMEZONE, diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json index 7e4fcb40075d5..bdf512b1e13bc 100644 --- a/packages/common/enabled-features/enabled-features.json +++ b/packages/common/enabled-features/enabled-features.json @@ -34,6 +34,7 @@ "dashboard_auth:sign_up": true, "dashboard_auth:sign_in_with_github": true, + "dashboard_auth:sign_in_with_chatgpt": true, "dashboard_auth:sign_in_with_sso": true, "dashboard_auth:sign_in_with_email": true, "dashboard_auth:show_testimonial": true, diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json index 0a63c3e2b63d9..4b4694ad13f1e 100644 --- a/packages/common/enabled-features/enabled-features.schema.json +++ b/packages/common/enabled-features/enabled-features.schema.json @@ -115,6 +115,10 @@ "type": "boolean", "description": "Enable the sign in with github provider" }, + "dashboard_auth:sign_in_with_chatgpt": { + "type": "boolean", + "description": "Enable the sign in with chatgpt provider" + }, "dashboard_auth:sign_in_with_sso": { "type": "boolean", "description": "Enable the sign in with sso provider" @@ -468,6 +472,7 @@ "billing:all", "dashboard_auth:sign_up", "dashboard_auth:sign_in_with_github", + "dashboard_auth:sign_in_with_chatgpt", "dashboard_auth:sign_in_with_sso", "dashboard_auth:sign_in_with_email", "dashboard_auth:show_tos", diff --git a/packages/common/package.json b/packages/common/package.json index 714cc1059b209..cf143eb26199b 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -34,6 +34,7 @@ "@vitest/ui": "catalog:", "tsconfig": "workspace:*", "type-fest": "5.6.0", + "@typescript/native": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 5ecb01633e3fb..659541bcc9ef2 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -694,6 +694,19 @@ export interface SqlEditorTemplateClickedEvent { groups: TelemetryGroups } +/** + * User clicked the "Disable" button next to the autosave status text in the + * SQL Editor, to open the feature preview modal for manual snippet saving. + * + * @group Events + * @source studio + * @page /project/{ref}/sql/{id} + */ +export interface SqlEditorAutosaveDisableClickedEvent { + action: 'sql_editor_autosave_disable_clicked' + groups: TelemetryGroups +} + /** * User clicked the "Result download CSV" button in the SQL editor. * @@ -3583,6 +3596,7 @@ export type TelemetryEvent = | TableRealtimeDisabledEvent | SqlEditorQuickstartClickedEvent | SqlEditorTemplateClickedEvent + | SqlEditorAutosaveDisableClickedEvent | SqlEditorResultDownloadCsvClickedEvent | SqlEditorResultCopyMarkdownClickedEvent | SqlEditorResultCopyJsonClickedEvent diff --git a/packages/config/package.json b/packages/config/package.json index 0645eabf1caf3..324105ef29b48 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -15,6 +15,7 @@ "@tailwindcss/postcss": "^4.2.4", "tailwindcss": "catalog:", "tw-animate-css": "^1.4.0", + "@typescript/native": "catalog:", "typescript": "catalog:" } } diff --git a/packages/dev-tools/package.json b/packages/dev-tools/package.json index 92a11f7a1ddd9..7d32a3ee69473 100644 --- a/packages/dev-tools/package.json +++ b/packages/dev-tools/package.json @@ -31,6 +31,7 @@ "next-router-mock": "^0.9.13", "tailwindcss": "catalog:", "tsconfig": "workspace:*", + "@typescript/native": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/packages/eslint-config-supabase/package.json b/packages/eslint-config-supabase/package.json index c4a70a1cd28d2..878e68ca8e173 100644 --- a/packages/eslint-config-supabase/package.json +++ b/packages/eslint-config-supabase/package.json @@ -16,6 +16,7 @@ "eslint-config-next": "^15.5.0", "eslint-config-prettier": "^10.0.0", "eslint-config-turbo": "^2.5.0", + "@typescript/native": "catalog:", "typescript": "catalog:" } } diff --git a/packages/icons/package.json b/packages/icons/package.json index d222c83c9948e..dc5bc56c5cf8f 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -7,6 +7,7 @@ "clean": "rimraf node_modules .turbo" }, "dependencies": { + "@typescript/native": "catalog:", "typescript": "catalog:", "@supabase/build-icons": "workspace:*" }, diff --git a/packages/marketing/package.json b/packages/marketing/package.json index 4f434138aec1b..f9274b2f2e370 100644 --- a/packages/marketing/package.json +++ b/packages/marketing/package.json @@ -25,6 +25,7 @@ "config": "workspace:*", "tailwindcss": "^4.2.4", "tsconfig": "workspace:", + "@typescript/native": "catalog:", "typescript": "catalog:" }, "license": "MIT" diff --git a/packages/pg-meta/package.json b/packages/pg-meta/package.json index 5adb1dd464cee..94f2cc959478a 100644 --- a/packages/pg-meta/package.json +++ b/packages/pg-meta/package.json @@ -22,6 +22,7 @@ "npm-run-all": "^4.1.5", "pg": "^8.13.1", "postgres-array": "^3.0.2", + "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/packages/pg-meta/src/sql/studio/database/rows.ts b/packages/pg-meta/src/sql/studio/database/rows.ts index c4657bbe2a082..ad6e751745353 100644 --- a/packages/pg-meta/src/sql/studio/database/rows.ts +++ b/packages/pg-meta/src/sql/studio/database/rows.ts @@ -12,12 +12,13 @@ export const getTableRowsCountSql = ({ table, filters = [], enforceExactCount = false, - isUsingReadReplica = false, + isReadOnlyContext = false, }: { table: any filters?: Filter[] enforceExactCount?: boolean - isUsingReadReplica?: boolean + /** Skips using the count estimate function if true and fallsback to checking reltuples from pg_class */ + isReadOnlyContext?: boolean }): SafeSqlFragment => { if (!table) return safeSql`` @@ -59,7 +60,7 @@ export const getTableRowsCountSql = ({ ? (countBaseSql.slice(0, -1) as SafeSqlFragment) : countBaseSql - if (isUsingReadReplica) { + if (isReadOnlyContext) { const sql = safeSql` with approximation as ( select reltuples as estimate diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index a4c164bd59e97..c9dd3e0956a28 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -797,6 +797,7 @@ "next-router-mock": "^0.9.13", "tailwindcss": "^4.2.4", "tsx": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:", "unified": "^11.0.5", "vfile": "^6.0.3", diff --git a/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx b/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx index 8fd0d29552f09..1243173bf6642 100644 --- a/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx +++ b/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx @@ -222,7 +222,7 @@ export const CodeBlock = ({ : 'var(--background-selection)', borderLeft: highlightBorder ? `1px solid ${styleConfig?.highlightBorderColor ? styleConfig?.highlightBorderColor : 'var(--foreground-default)'}` - : null, + : undefined, }, class: 'hljs-line-highlight', } diff --git a/packages/ui/package.json b/packages/ui/package.json index 43f2ac880e048..a724528e4fc11 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,6 +56,7 @@ "config": "workspace:*", "tsconfig": "workspace:*", "tsx": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cac46b981b07b..7f1e6200ce640 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ catalogs: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3 + '@typescript/native': + specifier: npm:typescript@~7.0.2 + version: 7.0.2 '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1 @@ -79,7 +82,7 @@ catalogs: specifier: ^4.22.0 version: 4.22.4 typescript: - specifier: ~6.0.0 + specifier: ~6.0.2 version: 6.0.2 valtio: specifier: ^1.12.0 @@ -140,6 +143,9 @@ importers: '@types/node': specifier: 'catalog:' version: 22.13.14 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 eslint: specifier: ^9.0.0 version: 9.37.0(jiti@2.7.0)(supports-color@8.1.1) @@ -294,6 +300,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 config: specifier: workspace:* version: link:../../packages/config @@ -637,6 +646,9 @@ importers: '@types/unist': specifier: ^2.0.6 version: 2.0.8 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 amaro: specifier: ^1.1.5 version: 1.1.5 @@ -1300,6 +1312,9 @@ importers: '@types/zxcvbn': specifier: ^4.4.1 version: 4.4.2 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vercel/config': specifier: ^0.2.1 version: 0.2.1 @@ -1565,6 +1580,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 config: specifier: workspace:^ version: link:../../packages/config @@ -1661,6 +1679,9 @@ importers: '@supabase/supabase-js': specifier: 'catalog:' version: 2.110.1 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vercel/og': specifier: ^0.6.2 version: 0.6.2 @@ -1954,12 +1975,18 @@ importers: specifier: ^4.5.1 version: 4.5.1(vue@3.5.35(typescript@6.0.2)) devDependencies: + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 shadcn: specifier: ^3.3.1 version: 3.3.1(@types/node@22.13.14)(babel-plugin-macros@3.1.0)(supports-color@8.1.1)(typescript@6.0.2) tsconfig: specifier: workspace:* version: link:../../packages/tsconfig + typescript: + specifier: 'catalog:' + version: 6.0.2 vite: specifier: ^7.3.2 version: 7.3.5(@types/node@22.13.14)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.22.4)(yaml@2.9.0) @@ -2034,6 +2061,9 @@ importers: '@types/node': specifier: 'catalog:' version: 22.13.14 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 api-types: specifier: workspace:* version: link:../api-types @@ -2067,6 +2097,9 @@ importers: packages/api-types: devDependencies: + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 openapi-typescript: specifier: ^7.4.3 version: 7.5.2(encoding@0.1.13)(typescript@6.0.2) @@ -2152,6 +2185,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -2183,6 +2219,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.2.4 version: 4.2.4 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 tailwindcss: specifier: 'catalog:' version: 4.2.4 @@ -2229,6 +2268,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 config: specifier: workspace:* version: link:../config @@ -2265,6 +2307,9 @@ importers: '@typescript-eslint/parser': specifier: ^8.48.0 version: 8.48.0(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 eslint-config-next: specifier: ^15.5.0 version: 15.5.4(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) @@ -2304,6 +2349,9 @@ importers: '@supabase/build-icons': specifier: workspace:* version: link:../build-icons + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 react: specifier: 'catalog:' version: 19.2.6 @@ -2339,6 +2387,9 @@ importers: specifier: 'catalog:' version: 3.25.76 devDependencies: + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 config: specifier: workspace:* version: link:../config @@ -2361,6 +2412,9 @@ importers: '@types/pg': specifier: ^8.11.11 version: 8.11.11 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -2503,6 +2557,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.14) + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -2714,6 +2771,9 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -8595,6 +8655,126 @@ packages: resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@typescript/vfs@1.6.1': resolution: {integrity: sha512-JwoxboBh7Oz1v38tPbkrZ62ZXNHAk9bJ7c9x0eI5zBfBnBYGhURdbnh7Z4smN/MV48Y5OCcZb58n972UtbazsA==} peerDependencies: @@ -16459,6 +16639,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + ua-parser-js@1.0.40: resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==} hasBin: true @@ -24508,6 +24693,66 @@ snapshots: '@typescript-eslint/types': 8.48.0 eslint-visitor-keys: 4.2.1 + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + '@typescript/vfs@1.6.1(supports-color@8.1.1)(typescript@6.0.2)': dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -34198,6 +34443,29 @@ snapshots: typescript@6.0.2: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + ua-parser-js@1.0.40: {} uc.micro@2.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68c237b24b75f..4112912416c3d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,6 +20,12 @@ catalog: '@types/node': ^22.0.0 '@types/react': ^19.2.14 '@types/react-dom': ^19.2.3 + # TypeScript 7 has no programmatic API until 7.1, so `typescript` stays aliased + # to the 6.0-API compat package for tools that import it (typescript-eslint, + # Next.js build typechecking), while `@typescript/native` provides the native + # TS 7 `tsc` binary used by typecheck scripts. + # https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/ + '@typescript/native': npm:typescript@~7.0.2 '@vitejs/plugin-react': ^6.0.1 '@vitest/coverage-v8': ^4.1.4 '@vitest/ui': ^4.1.4 @@ -35,7 +41,7 @@ catalog: recharts: ^2.15.4 tailwindcss: ^4.2.4 tsx: ^4.22.0 - typescript: ~6.0.0 + typescript: ~6.0.2 valtio: ^1.12.0 vite: ^8.0.16 vite-tsconfig-paths: ^6.1.1 @@ -60,6 +66,8 @@ minimumReleaseAgeExclude: - '@ai-sdk/*' - '@supabase/*' - '@supabase-labs/*' + - typescript + - '@typescript/*' # First-party, published from supabase-community/mdast-jsx. - mdast-jsx # The following are excluded to fix vulnerablities.