diff --git a/apps/docs/content/guides/functions/websockets.mdx b/apps/docs/content/guides/functions/websockets.mdx
index b9741bb7c60be..e4d15d7ae9dfa 100644
--- a/apps/docs/content/guides/functions/websockets.mdx
+++ b/apps/docs/content/guides/functions/websockets.mdx
@@ -32,7 +32,7 @@ Here are some basic examples of setting up WebSocket servers using Deno and Node
Deno.serve((req) => {
const upgrade = req.headers.get('upgrade') || ''
- if (upgrade.toLowerCase() != 'WebSocket') {
+ if (upgrade.toLowerCase() != 'websocket') {
return new Response("request isn't trying to upgrade to WebSocket.", { status: 400 })
}
diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx
index c6774d0803c5c..70def772d885d 100644
--- a/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx
+++ b/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx
@@ -1,32 +1,29 @@
-import Table from 'components/to-be-cleaned/Table'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
-import { Toggle } from 'ui'
+import { Switch, TableCell, TableRow } from 'ui'
export interface PublicationSkeletonProps {
index?: number
}
-const PublicationSkeleton = ({ index }: PublicationSkeletonProps) => {
+export const PublicationSkeleton = ({ index }: PublicationSkeletonProps) => {
return (
-
-
+
+
-
-
+
+
-
+
{Array.from({ length: 4 }).map((_, i) => (
-
-
-
+
+
+
))}
-
+
-
-
+
+
)
}
-
-export default PublicationSkeleton
diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx
index f24078292b20b..deb947dd863c9 100644
--- a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx
+++ b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx
@@ -1,34 +1,52 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { noop } from 'lodash'
-import { AlertCircle, Search } from 'lucide-react'
+import { AlertCircle, Info, Search } from 'lucide-react'
+import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
-import { Button, Input, Toggle } from 'ui'
-import Table from 'components/to-be-cleaned/Table'
+import { useParams } from 'common'
+import AlertError from 'components/ui/AlertError'
import InformationBox from 'components/ui/InformationBox'
import NoSearchResults from 'components/ui/NoSearchResults'
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import {
+ Button,
+ Card,
+ Switch,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from 'ui'
+import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
-import PublicationSkeleton from './PublicationSkeleton'
+import { PublicationSkeleton } from './PublicationSkeleton'
interface PublicationEvent {
event: string
key: string
}
-interface PublicationsListProps {
- onSelectPublication: (id: number) => void
-}
-
-export const PublicationsList = ({ onSelectPublication = noop }: PublicationsListProps) => {
+export const PublicationsList = () => {
+ const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [filterString, setFilterString] = useState('')
- const { data, isLoading } = useDatabasePublicationsQuery({
+ const {
+ data = [],
+ error,
+ isLoading,
+ isSuccess,
+ isError,
+ } = useDatabasePublicationsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
@@ -48,10 +66,11 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis
{ event: 'Delete', key: 'publish_delete' },
{ event: 'Truncate', key: 'publish_truncate' },
]
- const publications =
+ const publications = (
filterString.length === 0
- ? data ?? []
- : (data ?? []).filter((publication) => publication.name.includes(filterString))
+ ? data
+ : data.filter((publication) => publication.name.includes(filterString))
+ ).sort((a, b) => a.id - b.id)
const [toggleListenEventValue, setToggleListenEventValue] = useState<{
publication: any
@@ -79,8 +98,9 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis
}
- placeholder={'Filter'}
+ icon={}
+ className="w-48 pl-8"
+ placeholder="Search for a publication"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
/>
@@ -97,32 +117,62 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis
-
Name,
- System ID,
- Insert,
- Update,
- Delete,
- Truncate,
-
- Source
- ,
- ]}
- body={
- isLoading
- ? Array.from({ length: 5 }).map((_, i) => )
- : publications.map((x) => (
-
- {x.name}
- {x.id}
+
+
+
+
+ Name
+ System ID
+ Insert
+ Update
+ Delete
+ Truncate
+
+
+
+
+ {isLoading &&
+ Array.from({ length: 2 }).map((_, i) => )}
+
+ {isError && (
+
+
+
+
+
+ )}
+
+ {isSuccess &&
+ publications.map((x) => (
+
+
+ {x.name}
+ {/* [Joshen] Making this tooltip very specific for these 2 publications */}
+ {['supabase_realtime', 'supabase_realtime_messages_publication'].includes(
+ x.name
+ ) && (
+
+
+
+
+
+ {x.name === 'supabase_realtime'
+ ? 'This publication is managed by Supabase and handles Postgres changes'
+ : x.name === 'supabase_realtime_messages_publication'
+ ? 'This publication is managed by Supabase and handles broadcasts from the database'
+ : undefined}
+
+
+ )}
+
+ {x.id}
{publicationEvents.map((event) => (
-
-
+ {
+ onClick={() => {
setToggleListenEventValue({
publication: x,
event,
@@ -130,27 +180,24 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis
})
}}
/>
-
+
))}
-
-
-
+
{!isLoading && publications.length === 0 && (
diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx
index 0ae7b75c7f993..ad6f35e7d96c9 100644
--- a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx
+++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx
@@ -3,11 +3,11 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import { toast } from 'sonner'
-import Table from 'components/to-be-cleaned/Table'
import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { Badge, Toggle } from 'ui'
+import { useProtectedSchemas } from 'hooks/useProtectedSchemas'
+import { Badge, Switch, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
interface PublicationsTableItemProps {
table: PostgresTable
@@ -16,8 +16,11 @@ interface PublicationsTableItemProps {
const PublicationsTableItem = ({ table, selectedPublication }: PublicationsTableItemProps) => {
const { data: project } = useSelectedProjectQuery()
+ const { data: protectedSchemas } = useProtectedSchemas()
const enabledForAllTables = selectedPublication.tables == null
+ const isProtected = protectedSchemas.map((x) => x.name).includes(table.schema)
+
const [checked, setChecked] = useState(
selectedPublication.tables?.find((x: any) => x.id == table.id) != undefined
)
@@ -70,13 +73,13 @@ const PublicationsTableItem = ({ table, selectedPublication }: PublicationsTable
}
return (
-
- {table.name}
- {table.schema}
-
+
+ {table.name}
+ {table.schema}
+
{table.comment}
-
-
+
+
{enabledForAllTables ? (
@@ -84,18 +87,25 @@ const PublicationsTableItem = ({ table, selectedPublication }: PublicationsTable
for all tables
) : (
- toggleReplicationForTable(table, selectedPublication)}
- />
+
+
+ toggleReplicationForTable(table, selectedPublication)}
+ />
+
+ {isProtected && (
+
+ This table belongs to a protected schema, and its publication cannot be toggled
+
+ )}
+
)}
-
-
+
+
)
}
diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx
index ec191ac0ce781..5200c4e3a0140 100644
--- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx
+++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx
@@ -1,39 +1,38 @@
-import type { PostgresPublication } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ChevronLeft, Search } from 'lucide-react'
+import Link from 'next/link'
import { useMemo, useState } from 'react'
+import { useParams } from 'common'
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
-import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { Loading } from 'components/ui/Loading'
+import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { useProtectedSchemas } from 'hooks/useProtectedSchemas'
-import { Button, Input } from 'ui'
+import { Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
import { Admonition } from 'ui-patterns'
+import { Input } from 'ui-patterns/DataInputs/Input'
import PublicationsTableItem from './PublicationsTableItem'
-interface PublicationsTablesProps {
- selectedPublication: PostgresPublication
- onSelectBack: () => void
-}
-
-export const PublicationsTables = ({
- selectedPublication,
- onSelectBack,
-}: PublicationsTablesProps) => {
+export const PublicationsTables = () => {
+ const { ref, id } = useParams()
const { data: project } = useSelectedProjectQuery()
const [filterString, setFilterString] = useState('')
const { can: canUpdatePublications, isLoading: isLoadingPermissions } =
useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'publications')
- const { data: protectedSchemas } = useProtectedSchemas()
+ const { data: publications = [] } = useDatabasePublicationsQuery({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
+ const selectedPublication = publications.find((pub) => pub.id === Number(id))
const {
- data: tablesData,
+ data: tablesData = [],
isLoading,
isSuccess,
isError,
@@ -42,33 +41,35 @@ export const PublicationsTables = ({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
+
const tables = useMemo(() => {
- return (tablesData || []).filter((table) =>
- filterString.length === 0
- ? !protectedSchemas.find((s) => s.name === table.schema)
- : !protectedSchemas.find((s) => s.name === table.schema) &&
- table.name.includes(filterString)
+ return tablesData.filter((table) =>
+ filterString.length === 0 ? table : table.name.includes(filterString)
)
- }, [tablesData, protectedSchemas, filterString])
+ }, [tablesData, filterString])
return (
<>
@@ -95,38 +96,42 @@ export const PublicationsTables = ({
) : (
-
Name,
- Schema,
-
- Description
- ,
-
- {/* Temporarily disable All tables toggle for publications. See https://github.com/supabase/supabase/pull/7233.
-
-
- All Tables
-
-
toggleReplicationForAllTables(publication, enabledForAllTables)}
- />
- */}
- ,
- ]}
- body={tables.map((table) => (
-
- ))}
- />
+
+
+
+
+ Name
+ Schema
+ Description
+ {/*
+ We've disabled All tables toggle for publications.
+ See https://github.com/supabase/supabase/pull/7233.
+ */}
+
+
+
+
+ {!!selectedPublication ? (
+ tables.map((table) => (
+
+ ))
+ ) : (
+
+
+ The selected publication with ID {id} cannot be found
+
+ Head back to the list of publications to select one from there
+
+
+
+ )}
+
+
+
))}
>
diff --git a/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx
index e45ead06f1037..4851d41d6ea17 100644
--- a/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx
+++ b/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx
@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { DocsButton } from 'components/ui/DocsButton'
+import { getTemporaryAPIKey } from 'data/api-keys/temp-api-keys-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import {
@@ -52,7 +53,7 @@ export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPo
setOpen(v)
}
- const onSubmit = () => {
+ const onSubmit = async () => {
setOpen(false)
sendEvent({
action: 'realtime_inspector_listen_channel_clicked',
@@ -61,8 +62,17 @@ export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPo
organization: org?.slug ?? 'Unknown',
},
})
+
+ let token = config.token
+
+ // [Joshen] Refresh if starting to listen + using temp API key, since it has a low refresh rate
+ if (token.startsWith('sb_temp')) {
+ const data = await getTemporaryAPIKey({ projectRef: config.projectRef, expiry: 3600 })
+ token = data.api_key
+ }
onChangeConfig({
...config,
+ token,
channelName: form.getValues('channel'),
isChannelPrivate: form.getValues('isPrivate'),
enabled: true,
diff --git a/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx b/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx
index 867438923c1d7..08f008921b18e 100644
--- a/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx
+++ b/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx
@@ -4,6 +4,7 @@ import { Dispatch, SetStateAction } from 'react'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { getTemporaryAPIKey } from 'data/api-keys/temp-api-keys-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
@@ -38,8 +39,16 @@ export const Header = ({ config, onChangeConfig }: HeaderProps) => {
className="rounded-l-none border-l-0"
disabled={!canReadAPIKeys || config.channelName.length === 0}
icon={config.enabled ? : }
- onClick={() => {
- onChangeConfig({ ...config, enabled: !config.enabled })
+ onClick={async () => {
+ // [Joshen] Refresh if starting to listen + using temp API key, since it has a low refresh rate
+ if (!config.enabled && config.token.startsWith('sb_temp')) {
+ const data = await getTemporaryAPIKey({ projectRef: config.projectRef, expiry: 3600 })
+ const token = data.api_key
+ onChangeConfig({ ...config, token, enabled: !config.enabled })
+ } else {
+ onChangeConfig({ ...config, enabled: !config.enabled })
+ }
+
if (!config.enabled) {
// the user has clicked to start listening
sendEvent({
diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx
index ca059755665fe..2ed1e7515e776 100644
--- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx
+++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx
@@ -69,7 +69,7 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens
.catch((err) => toast.error(`Failed to get JWT for role: ${err.message}`))
} else {
try {
- const data = await getTemporaryAPIKey({ projectRef: config.projectRef })
+ const data = await getTemporaryAPIKey({ projectRef: config.projectRef, expiry: 3600 })
token = data.api_key
} catch (error) {
token = publishableKey?.api_key
diff --git a/apps/studio/data/api-keys/temp-api-keys-query.ts b/apps/studio/data/api-keys/temp-api-keys-query.ts
index 4939727c23620..5a15846d269c6 100644
--- a/apps/studio/data/api-keys/temp-api-keys-query.ts
+++ b/apps/studio/data/api-keys/temp-api-keys-query.ts
@@ -2,12 +2,14 @@ import { handleError, post } from 'data/fetchers'
interface getTemporaryAPIKeyVariables {
projectRef?: string
+ /** In seconds, max: 3600 (an hour) */
+ expiry?: number
}
// [Joshen] This one specifically shouldn't need a useQuery hook since the expiry is meant to be short lived
// Used in storage explorer and realtime inspector.
export async function getTemporaryAPIKey(
- { projectRef }: getTemporaryAPIKeyVariables,
+ { projectRef, expiry = 300 }: getTemporaryAPIKeyVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
@@ -15,7 +17,10 @@ export async function getTemporaryAPIKey(
const { data, error } = await post('/platform/projects/{ref}/api-keys/temporary', {
params: {
path: { ref: projectRef },
- query: { authorization_exp: '300', claims: JSON.stringify({ role: 'service_role' }) },
+ query: {
+ authorization_exp: expiry.toString(),
+ claims: JSON.stringify({ role: 'service_role' }),
+ },
},
signal,
})
diff --git a/apps/studio/data/tables/tables-query.ts b/apps/studio/data/tables/tables-query.ts
index b7da827a5bb16..af715ea5b60ae 100644
--- a/apps/studio/data/tables/tables-query.ts
+++ b/apps/studio/data/tables/tables-query.ts
@@ -3,10 +3,10 @@ import { useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react-
import { sortBy } from 'lodash'
import { useCallback } from 'react'
+import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants'
import { get, handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { tableKeys } from './keys'
-import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants'
export type TablesVariables = {
projectRef?: string
diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts
index b1268e0a5345b..06eca90031d77 100644
--- a/apps/studio/instrumentation-client.ts
+++ b/apps/studio/instrumentation-client.ts
@@ -63,8 +63,12 @@ Sentry.init({
const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes(
`Failed to construct 'URL': Invalid URL`
)
+ // [Joshen] Similar behaviour for this error from SessionTimeoutModal to control the quota usage
+ const isSessionTimeoutEvent = (hint.originalException as any)?.message.includes(
+ 'Session error detected'
+ )
- if (isInvalidUrlEvent && Math.random() > 0.01) {
+ if ((isInvalidUrlEvent || isSessionTimeoutEvent) && Math.random() > 0.01) {
return null
}
diff --git a/apps/studio/pages/project/[ref]/database/publications.tsx b/apps/studio/pages/project/[ref]/database/publications.tsx
deleted file mode 100644
index fb3d57b306042..0000000000000
--- a/apps/studio/pages/project/[ref]/database/publications.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { useState } from 'react'
-
-import { PublicationsList } from 'components/interfaces/Database/Publications/PublicationsList'
-import { PublicationsTables } from 'components/interfaces/Database/Publications/PublicationsTables'
-import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
-import DefaultLayout from 'components/layouts/DefaultLayout'
-import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
-import { FormHeader } from 'components/ui/Forms/FormHeader'
-import NoPermission from 'components/ui/NoPermission'
-import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
-import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
-import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import type { NextPageWithLayout } from 'types'
-
-// [Joshen] Technically, best that we have these as separate URLs
-// makes it easier to manage state, but foresee that this page might
-// be consolidated somewhere else eventually for better UX
-
-const DatabasePublications: NextPageWithLayout = () => {
- const { data: project } = useSelectedProjectQuery()
-
- const { data } = useDatabasePublicationsQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- })
- const publications = data ?? []
-
- const { can: canViewPublications, isSuccess: isPermissionsLoaded } =
- useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications')
-
- const [selectedPublicationId, setSelectedPublicationId] = useState()
- const selectedPublication = publications.find((pub) => pub.id === selectedPublicationId)
-
- if (isPermissionsLoaded && !canViewPublications) {
- return
- }
-
- return (
-
-
-
-
- {selectedPublication === undefined ? (
-
- ) : (
-
setSelectedPublicationId(undefined)}
- />
- )}
-
-
-
- )
-}
-
-DatabasePublications.getLayout = (page) => (
-
- {page}
-
-)
-
-export default DatabasePublications
diff --git a/apps/studio/pages/project/[ref]/database/publications/[id].tsx b/apps/studio/pages/project/[ref]/database/publications/[id].tsx
new file mode 100644
index 0000000000000..81fec4d623a87
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/database/publications/[id].tsx
@@ -0,0 +1,37 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+
+import { PublicationsTables } from 'components/interfaces/Database/Publications/PublicationsTables'
+import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
+import NoPermission from 'components/ui/NoPermission'
+import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
+import type { NextPageWithLayout } from 'types'
+
+const DatabasePublications: NextPageWithLayout = () => {
+ const { can: canViewPublications, isSuccess: isPermissionsLoaded } =
+ useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications')
+
+ if (isPermissionsLoaded && !canViewPublications) {
+ return
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+DatabasePublications.getLayout = (page) => (
+
+
+ {page}
+
+
+)
+
+export default DatabasePublications
diff --git a/apps/studio/pages/project/[ref]/database/publications/index.tsx b/apps/studio/pages/project/[ref]/database/publications/index.tsx
new file mode 100644
index 0000000000000..b5ee05c33dcd2
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/database/publications/index.tsx
@@ -0,0 +1,37 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+
+import { PublicationsList } from 'components/interfaces/Database/Publications/PublicationsList'
+import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
+import NoPermission from 'components/ui/NoPermission'
+import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
+import type { NextPageWithLayout } from 'types'
+
+const DatabasePublications: NextPageWithLayout = () => {
+ const { can: canViewPublications, isSuccess: isPermissionsLoaded } =
+ useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications')
+
+ if (isPermissionsLoaded && !canViewPublications) {
+ return
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+DatabasePublications.getLayout = (page) => (
+
+
+ {page}
+
+
+)
+
+export default DatabasePublications
diff --git a/apps/www/app/api-v2/luma-events/route.tsx b/apps/www/app/api-v2/luma-events/route.tsx
index 9d7fc28ddc632..ffd30e14daa8c 100644
--- a/apps/www/app/api-v2/luma-events/route.tsx
+++ b/apps/www/app/api-v2/luma-events/route.tsx
@@ -1,3 +1,4 @@
+import * as Sentry from '@sentry/nextjs'
import { NextRequest, NextResponse } from 'next/server'
export interface LumaGeoAddressJson {
@@ -115,6 +116,7 @@ export async function GET(request: NextRequest) {
},
})
} catch (error) {
+ Sentry.captureException(error)
console.error('Error fetching meetups from Luma:', error)
return NextResponse.json(
{
diff --git a/apps/www/app/api-v2/opt-out/[ref]/route.ts b/apps/www/app/api-v2/opt-out/[ref]/route.ts
index 9028a6934b29a..8cc41ee481dde 100644
--- a/apps/www/app/api-v2/opt-out/[ref]/route.ts
+++ b/apps/www/app/api-v2/opt-out/[ref]/route.ts
@@ -1,5 +1,6 @@
-import { NextRequest, NextResponse } from 'next/server'
+import * as Sentry from '@sentry/nextjs'
import { createClient } from '@supabase/supabase-js'
+import { NextRequest, NextResponse } from 'next/server'
const supabaseUrl = process.env.NEXT_PUBLIC_EMAIL_ABUSE_URL as string
const supabaseServiceKey = process.env.EMAIL_ABUSE_SERVICE_KEY as string
@@ -78,7 +79,9 @@ export async function POST(req: NextRequest, props: { params: Promise<{ ref: str
.from('manual_reports')
.insert([{ project_ref: ref, reason, email }])
- if (supabaseError) throw new Error(`Supabase error: ${supabaseError.message}`)
+ if (supabaseError) {
+ throw new Error(`Supabase error: ${supabaseError.message}`)
+ }
const response = await fetch(process.env.EMAIL_REPORT_SLACK_WEBHOOK as string, {
method: 'POST',
@@ -93,6 +96,7 @@ export async function POST(req: NextRequest, props: { params: Promise<{ ref: str
{ status: 200 }
)
} catch (error) {
+ Sentry.captureException(error)
const errorMessage = (error as Error).message
return NextResponse.json(
{ error: `Failure: Could not send post to Slack. Error: ${errorMessage}` },
diff --git a/apps/www/app/api-v2/submit-form-contact-sales/route.tsx b/apps/www/app/api-v2/submit-form-contact-sales/route.tsx
index 4f2e1aed08eb9..a8e2ef53720dd 100644
--- a/apps/www/app/api-v2/submit-form-contact-sales/route.tsx
+++ b/apps/www/app/api-v2/submit-form-contact-sales/route.tsx
@@ -1,3 +1,5 @@
+import * as Sentry from '@sentry/nextjs'
+
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
@@ -92,6 +94,7 @@ export async function POST(req: Request) {
if (!response.ok) {
const errorData = await response.json()
+ Sentry.captureException(errorData)
return new Response(JSON.stringify({ message: errorData.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: response.status,
@@ -103,6 +106,7 @@ export async function POST(req: Request) {
status: 200,
})
} catch (error: any) {
+ Sentry.captureException(error)
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
diff --git a/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx b/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx
index 856845ad620f7..ed9c6a668ebe3 100644
--- a/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx
+++ b/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx
@@ -1,3 +1,5 @@
+import * as Sentry from '@sentry/nextjs'
+
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
@@ -56,6 +58,7 @@ export async function POST(req: Request) {
if (!response.ok) {
const errorData = await response.json()
+ Sentry.captureException(errorData)
return new Response(JSON.stringify({ message: errorData.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: response.status,
@@ -67,6 +70,7 @@ export async function POST(req: Request) {
status: 200,
})
} catch (error: any) {
+ Sentry.captureException(error)
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
diff --git a/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx b/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx
index b41b906430061..a4d6561928055 100644
--- a/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx
+++ b/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx
@@ -1,3 +1,5 @@
+import * as Sentry from '@sentry/nextjs'
+
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
@@ -91,6 +93,7 @@ export async function POST(req: Request) {
if (!response.ok) {
const errorData = await response.json()
+ Sentry.captureException(errorData)
return new Response(JSON.stringify({ message: errorData.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: response.status,
@@ -102,6 +105,7 @@ export async function POST(req: Request) {
status: 200,
})
} catch (error: any) {
+ Sentry.captureException(error)
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
diff --git a/apps/www/app/api-v2/ticket-og/route.tsx b/apps/www/app/api-v2/ticket-og/route.tsx
index 61f4cc0fdeb3e..6c9ba7807a3db 100644
--- a/apps/www/app/api-v2/ticket-og/route.tsx
+++ b/apps/www/app/api-v2/ticket-og/route.tsx
@@ -1,6 +1,6 @@
-import React from 'react'
-import { ImageResponse } from '@vercel/og'
+import * as Sentry from '@sentry/nextjs'
import { createClient } from '@supabase/supabase-js'
+import { ImageResponse } from '@vercel/og'
import useTicketBg from 'components/LaunchWeek/15/hooks/use-ticket-bg'
export const runtime = 'edge' // 'nodejs' is the default
@@ -501,6 +501,9 @@ export async function GET(req: Request, res: Response) {
return await fetch(`${STORAGE_URL}/og/${ticketType}/${username}.png?t=${NEW_TIMESTAMP}`)
} catch (error: any) {
+ Sentry.captureException(error)
+ await Sentry.flush(2000)
+
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
diff --git a/apps/www/instrumentation.ts b/apps/www/instrumentation.ts
new file mode 100644
index 0000000000000..3063091e693ee
--- /dev/null
+++ b/apps/www/instrumentation.ts
@@ -0,0 +1,13 @@
+import * as Sentry from '@sentry/nextjs'
+
+export async function register() {
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
+ await import('./sentry.server.config')
+ }
+
+ if (process.env.NEXT_RUNTIME === 'edge') {
+ await import('./sentry.edge.config')
+ }
+}
+
+export const onRequestError = Sentry.captureRequestError
diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs
index a04689a8a98e1..88390c9889d9b 100644
--- a/apps/www/next.config.mjs
+++ b/apps/www/next.config.mjs
@@ -1,5 +1,6 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import nextMdx from '@next/mdx'
+import { withSentryConfig } from '@sentry/nextjs'
import rehypeSlug from 'rehype-slug'
import remarkGfm from 'remark-gfm'
@@ -124,11 +125,37 @@ const nextConfig = {
}
// next.config.js.
-export default () => {
+const configExport = () => {
const plugins = [withContentlayer, withMDX, withBundleAnalyzer]
return plugins.reduce((acc, next) => next(acc), nextConfig)
}
+export default withSentryConfig(configExport, {
+ // For all available options, see:
+ // https://www.npmjs.com/package/@sentry/webpack-plugin#options
+
+ org: 'supabase',
+ project: 'www',
+
+ // Only print logs for uploading source maps in CI
+ silent: !process.env.CI,
+
+ // For all available options, see:
+ // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
+
+ // Upload a larger set of source maps for prettier stack traces (increases build time)
+ widenClientFileUpload: true,
+
+ // Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
+ // This can increase your server load as well as your hosting bill.
+ // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
+ // side errors will fail.
+ // tunnelRoute: "/monitoring",
+
+ // Automatically tree-shake Sentry logger statements to reduce bundle size
+ disableLogger: true,
+})
+
function getAssetPrefix() {
// If not force enabled, but not production env, disable CDN
if (process.env.FORCE_ASSET_CDN !== '1' && process.env.VERCEL_ENV !== 'production') {
diff --git a/apps/www/package.json b/apps/www/package.json
index a35b815912b6b..6f63357f1c526 100644
--- a/apps/www/package.json
+++ b/apps/www/package.json
@@ -30,6 +30,7 @@
"@octokit/plugin-paginate-graphql": "^4.0.0",
"@octokit/rest": "^21.0.0",
"@radix-ui/react-dialog": "^1.0.5",
+ "@sentry/nextjs": "^10",
"@supabase/supabase-js": "catalog:",
"@vercel/og": "^0.6.2",
"ai-commands": "workspace:*",
diff --git a/apps/www/sentry.edge.config.ts b/apps/www/sentry.edge.config.ts
new file mode 100644
index 0000000000000..9bc4d3e1e64d9
--- /dev/null
+++ b/apps/www/sentry.edge.config.ts
@@ -0,0 +1,16 @@
+// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
+// The config you add here will be used whenever one of the edge features is loaded.
+// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
+// https://docs.sentry.io/platforms/javascript/guides/nextjs/
+
+import * as Sentry from '@sentry/nextjs'
+
+Sentry.init({
+ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
+
+ // Enable logs to be sent to Sentry
+ enableLogs: true,
+
+ // Setting this option to true will print useful information to the console while you're setting up Sentry.
+ debug: false,
+})
diff --git a/apps/www/sentry.server.config.ts b/apps/www/sentry.server.config.ts
new file mode 100644
index 0000000000000..89dc5a230bfeb
--- /dev/null
+++ b/apps/www/sentry.server.config.ts
@@ -0,0 +1,15 @@
+// This file configures the initialization of Sentry on the server.
+// The config you add here will be used whenever the server handles a request.
+// https://docs.sentry.io/platforms/javascript/guides/nextjs/
+
+import * as Sentry from '@sentry/nextjs'
+
+Sentry.init({
+ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
+
+ // Enable logs to be sent to Sentry
+ enableLogs: true,
+
+ // Setting this option to true will print useful information to the console while you're setting up Sentry.
+ debug: false,
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 383f72de40be8..4214a59b26f3c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1553,6 +1553,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@sentry/nextjs':
+ specifier: ^10
+ version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.3(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2))
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.49.3
@@ -24785,6 +24788,32 @@ snapshots:
'@sentry/core@10.3.0': {}
+ '@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.3(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2))':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/semantic-conventions': 1.36.0
+ '@rollup/plugin-commonjs': 28.0.1(rollup@4.38.0)
+ '@sentry-internal/browser-utils': 10.3.0
+ '@sentry/core': 10.3.0
+ '@sentry/node': 10.3.0(supports-color@8.1.1)
+ '@sentry/opentelemetry': 10.3.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)
+ '@sentry/react': 10.3.0(react@18.3.1)
+ '@sentry/vercel-edge': 10.3.0
+ '@sentry/webpack-plugin': 4.0.2(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2))
+ chalk: 3.0.0
+ next: 15.3.3(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
+ resolve: 1.22.8
+ rollup: 4.38.0
+ stacktrace-parser: 0.1.10
+ transitivePeerDependencies:
+ - '@opentelemetry/context-async-hooks'
+ - '@opentelemetry/core'
+ - '@opentelemetry/sdk-trace-base'
+ - encoding
+ - react
+ - supports-color
+ - webpack
+
'@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.3(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -24864,6 +24893,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@sentry/opentelemetry@10.3.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0)
+ '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.36.0
+ '@sentry/core': 10.3.0
+
'@sentry/opentelemetry@10.3.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -24886,6 +24924,16 @@ snapshots:
'@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0)
'@sentry/core': 10.3.0
+ '@sentry/webpack-plugin@4.0.2(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2))':
+ dependencies:
+ '@sentry/bundler-plugin-core': 4.0.2(encoding@0.1.13)(supports-color@8.1.1)
+ unplugin: 1.0.1
+ uuid: 9.0.1
+ webpack: 5.94.0(esbuild@0.25.2)
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
'@sentry/webpack-plugin@4.0.2(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0)':
dependencies:
'@sentry/bundler-plugin-core': 4.0.2(encoding@0.1.13)(supports-color@8.1.1)
@@ -26786,7 +26834,7 @@ snapshots:
'@types/pg@8.15.4':
dependencies:
'@types/node': 22.13.14
- pg-protocol: 1.7.1
+ pg-protocol: 1.10.3
pg-types: 2.2.0
'@types/phoenix@1.6.4': {}
@@ -37040,7 +37088,6 @@ snapshots:
webpack: 5.94.0(esbuild@0.25.2)
optionalDependencies:
esbuild: 0.25.2
- optional: true
terser-webpack-plugin@5.3.14(webpack@5.94.0):
dependencies:
@@ -38409,7 +38456,6 @@ snapshots:
- '@swc/core'
- esbuild
- uglify-js
- optional: true
whatwg-encoding@2.0.0:
dependencies:
diff --git a/turbo.json b/turbo.json
index 6d010af23b408..0409cabc4c9ae 100644
--- a/turbo.json
+++ b/turbo.json
@@ -124,6 +124,7 @@
"NEXT_PUBLIC_HCAPTCHA_SITE_KEY",
"HCAPTCHA_SECRET_KEY",
"NODE_ENV",
+ "NEXT_PUBLIC_SENTRY_DSN",
// These envs are used in the packages
"NEXT_PUBLIC_STORAGE_KEY",
"NEXT_PUBLIC_AUTH_DEBUG_KEY",