+
+
+
+ {value.name}
+
+ {value.code}
+
-
- )
- })}
-
-
- >
- )}
-
- Specific regions
- {regionOptions.map((value) => {
- return (
- :nth-child(2)]:w-full',
- value.status !== undefined && 'pointer-events-auto!'
- )}
- disabled={value.status !== undefined}
- >
-
-
-
-
- {value.name}
-
- {value.code}
-
-
+ {recommendedSpecificRegions.has(value.code) && (
+
+ Recommended
+
+ )}
+ {value.status !== undefined && value.status === 'capacity' && (
+
+
+
+ Unavailable
+
+
+
+ Temporarily unavailable due to this region being at capacity.
+
+
+ )}
-
- {recommendedSpecificRegions.has(value.code) && (
-
- Recommended
-
- )}
- {value.status !== undefined && value.status === 'capacity' && (
-
-
-
- Unavailable
-
-
-
- Temporarily unavailable due to this region being at capacity.
-
-
- )}
-
-
- )
- })}
-
-
-
+
+ )
+ })}
+
+
+
+
{affectingIncidents.length > 0 && (
diff --git a/apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx b/apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx
new file mode 100644
index 0000000000000..3ecea2464f7ba
--- /dev/null
+++ b/apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx
@@ -0,0 +1,33 @@
+import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import { hasUnsavedChanges } from '@/state/sql-editor/sql-editor-lifecycle'
+import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
+import type { Tab } from '@/state/tabs'
+
+/** The snippet id a SQL tab represents. Prefer the metadata; fall back to the id scheme. */
+export function getSnippetIdFromTab(tab: Tab): string {
+ return tab.metadata?.sqlId ?? tab.id.replace(/^sql-/, '')
+}
+
+/**
+ * VS Code-style unsaved-changes dot for a SQL snippet tab. Renders only in
+ * manual-save mode when the snippet has unsaved edits — in auto mode edits
+ * persist on their own, so a dot would just flicker during the debounce.
+ *
+ * Registered as the SQL tab type's status indicator (see the save coordinator)
+ * so the tabs layout can render it without knowing anything about snippets.
+ */
+export const SqlTabStatusIndicator = ({ tab }: { tab: Tab }) => {
+ const snapV2 = useSqlEditorV2StateSnapshot()
+ const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
+
+ const status = snapV2.snippets[getSnippetIdFromTab(tab)]?.snippet.status
+ if (!isManualSaveEnabled || !hasUnsavedChanges(status)) return null
+
+ return (
+
+ )
+}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx
new file mode 100644
index 0000000000000..abff6c14b7aa8
--- /dev/null
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx
@@ -0,0 +1,70 @@
+import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
+import { PowerOff } from 'lucide-react'
+import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+
+import {
+ useFeaturePreviewModal,
+ useIsSqlEditorManualSaveEnabled,
+} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import { IS_PLATFORM } from '@/lib/constants'
+import { useTrack } from '@/lib/telemetry/track'
+import { hasUnsavedChanges } from '@/state/sql-editor/sql-editor-lifecycle'
+import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
+
+export type AutosaveStatusProps = { id: string }
+
+export const AutosaveStatus = ({ id }: AutosaveStatusProps) => {
+ const snapV2 = useSqlEditorV2StateSnapshot()
+ const track = useTrack()
+ const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
+ const { selectFeaturePreview } = useFeaturePreviewModal()
+
+ // Whether the manual-save preview can actually be opted into. Mirrors the
+ // feature preview modal's own filter (platform-only + ConfigCat flag), so we
+ // don't offer to "disable autosave" when there's no preview to switch to.
+ // `isManualSaveEnabled` also being false for self-hosted / flag-off users is
+ // why it can't gate this affordance.
+ const sqlEditorManualSaveFlag = useFlag('sqlEditorManualSave')
+ const canEnableManualSave = IS_PLATFORM && sqlEditorManualSaveFlag
+
+ if (isManualSaveEnabled) {
+ const snippet = snapV2.snippets[id]
+ // A snippet only enters the store on its first edit, so a snippet that
+ // isn't in the store yet is a fresh, blank, untouched "new query" tab —
+ // there's nothing to report a save status for.
+ if (snippet === undefined) return null
+
+ const unsavedChanges = hasUnsavedChanges(snippet.snippet.status)
+
+ return (
+
+ {unsavedChanges ? 'Unsaved edits' : 'Saved'}
+
+ )
+ }
+
+ return (
+
+ Autosave enabled
+ {canEnableManualSave && (
+
+
+ }
+ onClick={() => {
+ track('sql_editor_autosave_disable_clicked')
+ selectFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE)
+ }}
+ />
+
+ Disable autosave (feature preview)
+
+ )}
+
+ )
+}
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 (
+
requestSave(id)}
+ disabled={!isDirty}
+ variant="default"
+ size="tiny"
+ data-testid="sql-save-button"
+ iconRight={
+ saving ? (
+
+ ) : (
+
+ )
+ }
+ className={className}
+ >
+ Save
+
+ )
+}
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 ? (
({
{
const editor = useEditorType()
const tabs = useTabsStateSnapshot()
+ // Reading the registration version subscribes this tab to handler (un)registers,
+ // so an indicator registered after first paint (handlers register in an effect)
+ // is still picked up. The layout stays agnostic of what the indicator shows.
+ void tabs.handlerRegistrationVersion
+ const StatusIndicator = tabs.getTabStatusIndicator(tab.type)
const { selectedSchema: currentSchema } = useQuerySchemaState()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
@@ -101,25 +106,35 @@ export const SortableTab = ({
{tab.label || 'Untitled'}
-
{
- 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.