mirror of
https://github.com/supabase/supabase.git
synced 2026-06-01 18:34:37 +08:00
Migrate Database Webhook integrations to new integration sUI (#44277)
## Context Related to marketplace integrations Shifts Database Webhooks integration to the new Integrations UI. This one's a bit different from the previous PRs as this involves a full SQL installation query instead of only just a database extension. So am tweaking `Integrations.constants` a little. For context eventually all the integrations will be pulled remotely from a database, so am still trying to figure out an optimal data structure, but requirements will be clearer as we build out the UI RE installing integrations: - For now, if the integration has a provided SQL installation command, that'll take precedence - Else, if the integration has a provided SQL installation query, we'll use that on the /query endpoint - Otherwise, if the integration only requires database extensions, dashboard will generate the queries to install the extensions - In the case of the former tho, we won't allow users to choose which schema to install the extension in too Just ping me if any clarification's required! ## To test - [ ] Verify that you can install the database webhooks with the new integration UI - [ ] Verify that behaviour is status quo without the new integration UI
This commit is contained in:
@@ -13,6 +13,7 @@ export interface IntegrationOverviewTabProps {
|
||||
actions?: ReactNode
|
||||
status?: string | ReactNode
|
||||
alert?: ReactNode
|
||||
hideRequiredExtensionsSection?: boolean
|
||||
}
|
||||
|
||||
export const IntegrationOverviewTab = ({
|
||||
@@ -20,6 +21,7 @@ export const IntegrationOverviewTab = ({
|
||||
alert,
|
||||
status,
|
||||
children,
|
||||
hideRequiredExtensionsSection = false,
|
||||
}: PropsWithChildren<IntegrationOverviewTabProps>) => {
|
||||
const { id } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
@@ -56,7 +58,7 @@ export const IntegrationOverviewTab = ({
|
||||
|
||||
<Separator />
|
||||
|
||||
{dependsOnExtension && (
|
||||
{dependsOnExtension && !hideRequiredExtensionsSection && (
|
||||
<div className="px-4 md:px-10 max-w-4xl flex flex-col gap-y-4">
|
||||
<h4>Required extensions</h4>
|
||||
<Card>
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SonnerProgress,
|
||||
Tabs_Shadcn_,
|
||||
TabsContent_Shadcn_,
|
||||
TabsList_Shadcn_,
|
||||
@@ -42,6 +41,7 @@ import { extensionsWithRecommendedSchemas } from '@/components/interfaces/Databa
|
||||
import { useDatabaseExtensionEnableMutation } from '@/data/database-extensions/database-extension-enable-mutation'
|
||||
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
|
||||
import { ResponseError } from '@/types'
|
||||
@@ -50,14 +50,33 @@ interface InstallIntegrationSheetProps {
|
||||
integration: IntegrationDefinition
|
||||
}
|
||||
|
||||
/**
|
||||
* [Joshen] Trying to figure out what the ideal data structure is between local + remote integrations
|
||||
* So it might be a bit messy for now as we get more context and build out this UI
|
||||
*
|
||||
* If the integration provides its own SQL installation command, we'll use that
|
||||
* Otherwise if the integration provides its own SQL installation query, we'll use that through the query endpoint
|
||||
* Else if the integration only requires extensions, dashboard will generate the queries and fire through the query endpoint
|
||||
*/
|
||||
|
||||
export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheetProps) => {
|
||||
const { requiredExtensions: requiredExtensionNames } = integration
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: protectedSchemas } = useProtectedSchemas({ excludeSchemas: ['extensions'] })
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isInstalling, setIsInstalling] = useState(false)
|
||||
|
||||
const {
|
||||
icon,
|
||||
name,
|
||||
installationSql,
|
||||
installationCommand,
|
||||
missingExtensionsAlert,
|
||||
requiredExtensions: requiredExtensionNames,
|
||||
} = integration
|
||||
|
||||
const allowExtensionCustomSchema = !installationSql
|
||||
|
||||
const defaultExtensionsSchema = Object.fromEntries(
|
||||
requiredExtensionNames.map((extName) => [extName, { schema: 'extensions', value: undefined }])
|
||||
)
|
||||
@@ -85,66 +104,34 @@ export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheet
|
||||
(schema) => !protectedSchemas.some((protectedSchema) => protectedSchema.name === schema.name)
|
||||
)
|
||||
|
||||
const { mutateAsync: executeSql } = useExecuteSqlMutation({ onError: () => {} })
|
||||
const { mutateAsync: enableExtension } = useDatabaseExtensionEnableMutation({ onError: () => {} })
|
||||
|
||||
const enableExtensionsSQL = getEnableExtensionsSQL({
|
||||
extensions: requiredExtensions,
|
||||
extensionsSchema,
|
||||
})
|
||||
const installationSQLContent = installationSql ?? enableExtensionsSQL
|
||||
|
||||
/**
|
||||
* [Joshen] This will be pretty simple for now, but will expand as we bring over more integrations to the new UI to see what else is needed
|
||||
*/
|
||||
const onInstallIntegration = async () => {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
setIsInstalling(true)
|
||||
const toastId = toast.loading(
|
||||
<SonnerProgress progress={0} message={`Installing ${integration.name}`} />
|
||||
)
|
||||
const toastId = toast.loading(`Installing ${name}`)
|
||||
|
||||
try {
|
||||
if (requiredExtensions.length > 0) {
|
||||
toast.loading(
|
||||
<SonnerProgress
|
||||
progress={0}
|
||||
message={`Installing ${integration.name}`}
|
||||
description={`Enabling ${requiredExtensions.length} database extension${requiredExtensions.length > 1 ? 's' : ''}`}
|
||||
/>,
|
||||
{ id: toastId }
|
||||
)
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
requiredExtensions.map((ext) => {
|
||||
const { name, default_version: version } = ext
|
||||
const createSchema = extensionsSchema[name].schema === 'custom'
|
||||
const schema =
|
||||
name === 'pg_cron'
|
||||
? 'pg_catalog'
|
||||
: createSchema
|
||||
? (extensionsSchema[name].value as string)
|
||||
: extensionsSchema[name].schema
|
||||
|
||||
return enableExtension({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
schema,
|
||||
name,
|
||||
version,
|
||||
cascade: true,
|
||||
createSchema,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const failure = results.find((r) => r.status === 'rejected')
|
||||
if (!!failure) throw new Error(failure.reason.message)
|
||||
if (!!installationCommand) {
|
||||
await installationCommand({ ref: project.ref })
|
||||
} else if (!!installationSql) {
|
||||
await installIntegrationViaSQL()
|
||||
} else {
|
||||
await installIntegrationExtensions()
|
||||
}
|
||||
|
||||
toast.success(`Successfully installed ${integration.name}`, { id: toastId })
|
||||
toast.success(`Successfully installed ${name}`, { id: toastId })
|
||||
setOpen(false)
|
||||
} catch (error) {
|
||||
toast.error(`Failed to install ${integration.name}: ${(error as ResponseError).message}`, {
|
||||
toast.error(`Failed to install ${name}: ${(error as ResponseError).message}`, {
|
||||
id: toastId,
|
||||
})
|
||||
} finally {
|
||||
@@ -152,6 +139,49 @@ export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheet
|
||||
}
|
||||
}
|
||||
|
||||
const installIntegrationViaSQL = async () => {
|
||||
if (!project) return console.error('Project is required')
|
||||
if (!installationSql) return console.error('Installation SQL is required')
|
||||
|
||||
const { ref: projectRef, connectionString } = project
|
||||
try {
|
||||
await executeSql({ projectRef, connectionString, sql: installationSql })
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const installIntegrationExtensions = async () => {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
const { ref: projectRef, connectionString } = project
|
||||
const results = await Promise.allSettled(
|
||||
requiredExtensions.map((ext) => {
|
||||
const { name, default_version: version } = ext
|
||||
const createSchema = extensionsSchema[name].schema === 'custom'
|
||||
const schema =
|
||||
name === 'pg_cron'
|
||||
? 'pg_catalog'
|
||||
: createSchema
|
||||
? (extensionsSchema[name].value as string)
|
||||
: extensionsSchema[name].schema
|
||||
|
||||
return enableExtension({
|
||||
projectRef,
|
||||
connectionString,
|
||||
schema,
|
||||
name,
|
||||
version,
|
||||
cascade: true,
|
||||
createSchema,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const failure = results.find((r) => r.status === 'rejected')
|
||||
if (!!failure) throw new Error(failure.reason.message)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
@@ -164,10 +194,10 @@ export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheet
|
||||
>
|
||||
<SheetHeader className="flex items-center gap-x-4">
|
||||
<div className="shrink-0 w-11 h-11 relative bg-white border rounded-md flex items-center justify-center">
|
||||
{integration.icon()}
|
||||
{icon()}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<SheetTitle>Install {integration.name}</SheetTitle>
|
||||
<SheetTitle>Install {name}</SheetTitle>
|
||||
<SheetDescription>Review and configure this integration</SheetDescription>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
@@ -181,7 +211,7 @@ export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasMissingExtensions && integration.missingExtensionsAlert}
|
||||
{hasMissingExtensions && missingExtensionsAlert}
|
||||
|
||||
<Card>
|
||||
<CardContent className="px-0 pt-1.5 pb-0">
|
||||
@@ -232,9 +262,9 @@ export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheet
|
||||
hideCopy
|
||||
hideLineNumbers
|
||||
language="pgsql"
|
||||
value={enableExtensionsSQL}
|
||||
value={installationSQLContent}
|
||||
wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-3')}
|
||||
className="border-0 rounded-none [&_code]:text-[12px] [&_code]:text-foreground"
|
||||
className="border-0 rounded-none [&_code]:text-[12px] [&_code]:text-foreground max-h-80"
|
||||
/>
|
||||
</TabsContent_Shadcn_>
|
||||
<TabsContent_Shadcn_ value="edge_functions" className="mt-0">
|
||||
@@ -245,95 +275,99 @@ export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheet
|
||||
</Card>
|
||||
</SheetSection>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
{allowExtensionCustomSchema && (
|
||||
<>
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<SheetSection>
|
||||
<Accordion_Shadcn_ type="single" collapsible>
|
||||
<AccordionItem_Shadcn_ value="advanced-settings" className="border-none">
|
||||
<AccordionTrigger_Shadcn_ className="font-normal gap-2 py-0 justify-between text-sm hover:no-underline">
|
||||
Advanced settings
|
||||
</AccordionTrigger_Shadcn_>
|
||||
<AccordionContent_Shadcn_ className="!pb-0 pt-3 [&>div]:flex [&>div]:flex-col [&>div]:gap-y-4">
|
||||
<p className="text-foreground-light">
|
||||
Select which schemas to install the database extensions under
|
||||
</p>
|
||||
{requiredExtensionNames.map((extName) => {
|
||||
const extMeta = extensionsSchema[extName as keyof typeof extensionsSchema]
|
||||
const { schema, value } = extMeta
|
||||
const recommendedSchema = extensionsWithRecommendedSchemas[extName]
|
||||
<SheetSection>
|
||||
<Accordion_Shadcn_ type="single" collapsible>
|
||||
<AccordionItem_Shadcn_ value="advanced-settings" className="border-none">
|
||||
<AccordionTrigger_Shadcn_ className="font-normal gap-2 py-0 justify-between text-sm hover:no-underline">
|
||||
Advanced settings
|
||||
</AccordionTrigger_Shadcn_>
|
||||
<AccordionContent_Shadcn_ className="!pb-0 pt-3 [&>div]:flex [&>div]:flex-col [&>div]:gap-y-4">
|
||||
<p className="text-foreground-light">
|
||||
Select which schemas to install the database extensions under
|
||||
</p>
|
||||
{requiredExtensionNames.map((extName) => {
|
||||
const extMeta = extensionsSchema[extName as keyof typeof extensionsSchema]
|
||||
const { schema, value } = extMeta
|
||||
const recommendedSchema = extensionsWithRecommendedSchemas[extName]
|
||||
|
||||
return (
|
||||
<FormItemLayout
|
||||
key={extName}
|
||||
isReactForm={false}
|
||||
layout="horizontal"
|
||||
label={extName}
|
||||
>
|
||||
<Select_Shadcn_
|
||||
value={schema}
|
||||
onValueChange={(schema) =>
|
||||
setExtensionsSchema((prev) => ({
|
||||
...prev,
|
||||
[extName]: {
|
||||
schema,
|
||||
value: schema === 'custom' ? extName : undefined,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger_Shadcn_>
|
||||
<SelectValue_Shadcn_ placeholder="Select a schema" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="custom">
|
||||
Create a new schema
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectSeparator_Shadcn_ />
|
||||
{availableSchemas.map((schema) => {
|
||||
return (
|
||||
<SelectItem_Shadcn_ key={schema.id} value={schema.name}>
|
||||
{schema.name}
|
||||
{schema.name === recommendedSchema ? (
|
||||
<Badge className="ml-2" variant="success">
|
||||
Recommended
|
||||
</Badge>
|
||||
) : schema.name === 'extensions' ? (
|
||||
<Badge className="ml-2">Default</Badge>
|
||||
) : null}
|
||||
</SelectItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
|
||||
{schema === 'custom' && (
|
||||
return (
|
||||
<FormItemLayout
|
||||
key={extName}
|
||||
isReactForm={false}
|
||||
className="mt-2"
|
||||
label="Provide a name for your new schema"
|
||||
layout="horizontal"
|
||||
label={extName}
|
||||
>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
<Select_Shadcn_
|
||||
value={schema}
|
||||
onValueChange={(schema) =>
|
||||
setExtensionsSchema((prev) => ({
|
||||
...prev,
|
||||
[extName]: {
|
||||
schema: prev[extName].schema,
|
||||
value: e.target.value,
|
||||
schema,
|
||||
value: schema === 'custom' ? extName : undefined,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Provide a name for your schema"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger_Shadcn_>
|
||||
<SelectValue_Shadcn_ placeholder="Select a schema" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="custom">
|
||||
Create a new schema
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectSeparator_Shadcn_ />
|
||||
{availableSchemas.map((schema) => {
|
||||
return (
|
||||
<SelectItem_Shadcn_ key={schema.id} value={schema.name}>
|
||||
{schema.name}
|
||||
{schema.name === recommendedSchema ? (
|
||||
<Badge className="ml-2" variant="success">
|
||||
Recommended
|
||||
</Badge>
|
||||
) : schema.name === 'extensions' ? (
|
||||
<Badge className="ml-2">Default</Badge>
|
||||
) : null}
|
||||
</SelectItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
|
||||
{schema === 'custom' && (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
className="mt-2"
|
||||
label="Provide a name for your new schema"
|
||||
>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
setExtensionsSchema((prev) => ({
|
||||
...prev,
|
||||
[extName]: {
|
||||
schema: prev[extName].schema,
|
||||
value: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Provide a name for your schema"
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)
|
||||
})}
|
||||
</AccordionContent_Shadcn_>
|
||||
</AccordionItem_Shadcn_>
|
||||
</Accordion_Shadcn_>
|
||||
</SheetSection>
|
||||
)
|
||||
})}
|
||||
</AccordionContent_Shadcn_>
|
||||
</AccordionItem_Shadcn_>
|
||||
</Accordion_Shadcn_>
|
||||
</SheetSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogSectionSeparator />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getEnableWebhooksSQL } from '@supabase/pg-meta'
|
||||
import { Clock5, Code2, Layers, Timer, Vault, Webhook } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from 'next/image'
|
||||
@@ -8,6 +9,9 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { UpgradeDatabaseAlert } from '../Queues/UpgradeDatabaseAlert'
|
||||
import { WRAPPERS } from '../Wrappers/Wrappers.constants'
|
||||
import { WrapperMeta } from '../Wrappers/Wrappers.types'
|
||||
import { enableDatabaseWebhooks } from '@/data/database/hooks-enable-mutation'
|
||||
import { invalidateSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { getQueryClient } from '@/data/query-client'
|
||||
import { BASE_PATH, DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export type Navigation = {
|
||||
@@ -48,6 +52,10 @@ export type IntegrationDefinition = {
|
||||
pageId: string | undefined
|
||||
childId: string | undefined
|
||||
}) => ComponentType<{}> | null
|
||||
/** SQL query for installing the entire integration */
|
||||
installationSql?: string
|
||||
/** Custom command to install the integration */
|
||||
installationCommand?: (props: { ref: string }) => Promise<void>
|
||||
} & (
|
||||
| { type: 'wrapper'; meta: WrapperMeta }
|
||||
| { type: 'postgres_extension' | 'custom' | 'oauth' | 'template' }
|
||||
@@ -230,7 +238,7 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
|
||||
'Send real-time data from your database to another system when a table event occurs',
|
||||
docsUrl: DOCS_URL,
|
||||
author: authorSupabase,
|
||||
requiredExtensions: [],
|
||||
requiredExtensions: ['pg_net'],
|
||||
navigation: [
|
||||
{
|
||||
route: 'overview',
|
||||
@@ -266,6 +274,12 @@ const SUPABASE_INTEGRATIONS: Array<IntegrationDefinition> = [
|
||||
}
|
||||
return null
|
||||
},
|
||||
installationSql: getEnableWebhooksSQL(),
|
||||
installationCommand: async ({ ref }: { ref: string }) => {
|
||||
const queryClient = getQueryClient()
|
||||
await enableDatabaseWebhooks({ ref })
|
||||
await invalidateSchemasQuery(queryClient, ref)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'data_api',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useHooksEnableMutation } from 'data/database/hooks-enable-mutation'
|
||||
@@ -11,11 +11,14 @@ import { Admonition } from 'ui-patterns'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
|
||||
import { IntegrationOverviewTabV2 } from '../Integration/IntegrationOverviewTabV2'
|
||||
|
||||
export const WebhooksOverviewTab = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
|
||||
|
||||
const {
|
||||
data: schemas,
|
||||
isSuccess: isSchemasLoaded,
|
||||
@@ -59,37 +62,42 @@ export const WebhooksOverviewTab = () => {
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationOverviewTab
|
||||
actions={
|
||||
isSchemasLoaded && isHooksEnabled ? null : (
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
type="default"
|
||||
title="Enable database webhooks on your project"
|
||||
>
|
||||
<p>
|
||||
Database Webhooks can be used to trigger serverless functions or send requests to an
|
||||
HTTP endpoint
|
||||
</p>
|
||||
<ButtonTooltip
|
||||
className="mt-2 w-fit"
|
||||
onClick={() => enableHooksForProject()}
|
||||
disabled={isEnablingHooks}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canReadWebhooks
|
||||
? 'You need additional permissions to enable webhooks'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
if (isMarketplaceEnabled) {
|
||||
return <IntegrationOverviewTabV2 />
|
||||
} else {
|
||||
return (
|
||||
<IntegrationOverviewTab
|
||||
hideRequiredExtensionsSection
|
||||
actions={
|
||||
isSchemasLoaded && isHooksEnabled ? null : (
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
type="default"
|
||||
title="Enable database webhooks on your project"
|
||||
>
|
||||
Enable webhooks
|
||||
</ButtonTooltip>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
<p>
|
||||
Database Webhooks can be used to trigger serverless functions or send requests to an
|
||||
HTTP endpoint
|
||||
</p>
|
||||
<ButtonTooltip
|
||||
className="mt-2 w-fit"
|
||||
onClick={() => enableHooksForProject()}
|
||||
disabled={isEnablingHooks}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canReadWebhooks
|
||||
? 'You need additional permissions to enable webhooks'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Enable webhooks
|
||||
</ButtonTooltip>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
Database Webhooks allow you to send real-time data from your database to another system whenever a table event occurs.
|
||||
|
||||
You can hook into three table events: `INSERT`, `UPDATE`, and `DELETE`. All events are fired after a database row is changed.
|
||||
Database Webhooks allow you to send real-time data from your database to another system whenever a table event occurs via an HTTP request or an Edge Function. You can hook into three table events: `INSERT`, `UPDATE`, and `DELETE`. These are fired after the row change is committed.
|
||||
|
||||
@@ -31,6 +31,7 @@ export * from './sql/studio/database'
|
||||
export * from './sql/studio/table-editor'
|
||||
export * from './sql/studio/sql-editor'
|
||||
export * from './sql/studio/role-impersonation'
|
||||
export * from './sql/studio/integrations'
|
||||
|
||||
export default {
|
||||
roles,
|
||||
|
||||
261
packages/pg-meta/src/sql/studio/integrations/enable-webhooks.ts
Normal file
261
packages/pg-meta/src/sql/studio/integrations/enable-webhooks.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
// Copied over from Management API here:
|
||||
// https://github.com/supabase/infrastructure/blob/develop/api/apps/mgmt-api/src/common/projects/project-database-webhooks.service.ts#L58
|
||||
|
||||
export const getCheckWebhooksEnabledSQL = () =>
|
||||
`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name = 'supabase_functions'
|
||||
) AS schema_exists;
|
||||
`.trim()
|
||||
|
||||
export const getEnableWebhooksSQL = () =>
|
||||
`
|
||||
BEGIN;
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_roles
|
||||
WHERE rolname = 'supabase_functions_admin'
|
||||
)
|
||||
THEN
|
||||
CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Event trigger for pg_net
|
||||
CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access()
|
||||
RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_event_trigger_ddl_commands() AS ev
|
||||
JOIN pg_extension AS ext
|
||||
ON ev.objid = ext.oid
|
||||
WHERE ext.extname = 'pg_net'
|
||||
)
|
||||
THEN
|
||||
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT FROM pg_extension
|
||||
WHERE extname = 'pg_net'
|
||||
-- all versions in use on existing projects as of 2025-02-20
|
||||
-- version 0.12.0 onwards don't need these applied
|
||||
AND extversion IN ('0.2', '0.6', '0.7', '0.7.1', '0.8', '0.10.0', '0.11.0')
|
||||
) THEN
|
||||
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
|
||||
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
|
||||
|
||||
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
|
||||
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
|
||||
|
||||
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net';
|
||||
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_event_trigger
|
||||
WHERE evtname = 'issue_pg_net_access'
|
||||
) THEN
|
||||
CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION')
|
||||
EXECUTE PROCEDURE extensions.grant_pg_net_access();
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- pg_net grants when extension is already enabled
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_extension
|
||||
WHERE extname = 'pg_net'
|
||||
)
|
||||
THEN
|
||||
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT FROM pg_extension
|
||||
WHERE extname = 'pg_net'
|
||||
-- all versions in use on existing projects as of 2025-02-20
|
||||
-- version 0.12.0 onwards don't need these applied
|
||||
AND extversion IN ('0.2', '0.6', '0.7', '0.7.1', '0.8', '0.10.0', '0.11.0')
|
||||
) THEN
|
||||
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
|
||||
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
|
||||
|
||||
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
|
||||
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
|
||||
|
||||
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
END IF;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Create pg_net extension
|
||||
CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions;
|
||||
|
||||
-- Create supabase_functions schema
|
||||
CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin;
|
||||
|
||||
GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role;
|
||||
|
||||
-- supabase_functions.migrations definition
|
||||
CREATE TABLE supabase_functions.migrations (
|
||||
version text PRIMARY KEY,
|
||||
inserted_at timestamptz NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Initial supabase_functions migration
|
||||
INSERT INTO supabase_functions.migrations (version) VALUES ('initial');
|
||||
|
||||
-- supabase_functions.hooks definition
|
||||
CREATE TABLE supabase_functions.hooks (
|
||||
id bigserial PRIMARY KEY,
|
||||
hook_table_id integer NOT NULL,
|
||||
hook_name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
request_id bigint
|
||||
);
|
||||
CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id);
|
||||
CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name);
|
||||
COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.';
|
||||
|
||||
CREATE FUNCTION supabase_functions.http_request()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
request_id bigint;
|
||||
payload jsonb;
|
||||
url text := TG_ARGV[0]::text;
|
||||
method text := TG_ARGV[1]::text;
|
||||
headers jsonb DEFAULT '{}'::jsonb;
|
||||
params jsonb DEFAULT '{}'::jsonb;
|
||||
timeout_ms integer DEFAULT 1000;
|
||||
BEGIN
|
||||
IF url IS NULL OR url = 'null' THEN
|
||||
RAISE EXCEPTION 'url argument is missing';
|
||||
END IF;
|
||||
|
||||
IF method IS NULL OR method = 'null' THEN
|
||||
RAISE EXCEPTION 'method argument is missing';
|
||||
END IF;
|
||||
|
||||
IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN
|
||||
headers = '{"Content-Type": "application/json"}'::jsonb;
|
||||
ELSE
|
||||
headers = TG_ARGV[2]::jsonb;
|
||||
END IF;
|
||||
|
||||
IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN
|
||||
params = '{}'::jsonb;
|
||||
ELSE
|
||||
params = TG_ARGV[3]::jsonb;
|
||||
END IF;
|
||||
|
||||
IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN
|
||||
timeout_ms = 1000;
|
||||
ELSE
|
||||
timeout_ms = TG_ARGV[4]::integer;
|
||||
END IF;
|
||||
|
||||
CASE
|
||||
WHEN method = 'GET' THEN
|
||||
SELECT http_get INTO request_id FROM net.http_get(
|
||||
url,
|
||||
params,
|
||||
headers,
|
||||
timeout_ms
|
||||
);
|
||||
WHEN method = 'POST' THEN
|
||||
payload = jsonb_build_object(
|
||||
'old_record', OLD,
|
||||
'record', NEW,
|
||||
'type', TG_OP,
|
||||
'table', TG_TABLE_NAME,
|
||||
'schema', TG_TABLE_SCHEMA
|
||||
);
|
||||
|
||||
SELECT http_post INTO request_id FROM net.http_post(
|
||||
url,
|
||||
payload,
|
||||
params,
|
||||
headers,
|
||||
timeout_ms
|
||||
);
|
||||
ELSE
|
||||
RAISE EXCEPTION 'method argument % is invalid', method;
|
||||
END CASE;
|
||||
|
||||
INSERT INTO supabase_functions.hooks
|
||||
(hook_table_id, hook_name, request_id)
|
||||
VALUES
|
||||
(TG_RELID, TG_NAME, request_id);
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$function$;
|
||||
|
||||
GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin;
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin;
|
||||
ALTER USER supabase_functions_admin SET search_path = "supabase_functions";
|
||||
ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin;
|
||||
ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin;
|
||||
ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin;
|
||||
GRANT supabase_functions_admin TO postgres;
|
||||
|
||||
-- Remove unused supabase_pg_net_admin role
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_roles
|
||||
WHERE rolname = 'supabase_pg_net_admin'
|
||||
)
|
||||
THEN
|
||||
REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin;
|
||||
DROP OWNED BY supabase_pg_net_admin;
|
||||
DROP ROLE supabase_pg_net_admin;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants');
|
||||
|
||||
ALTER function supabase_functions.http_request() SECURITY DEFINER;
|
||||
ALTER function supabase_functions.http_request() SET search_path = supabase_functions;
|
||||
REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role;
|
||||
|
||||
COMMIT;
|
||||
`.trim()
|
||||
1
packages/pg-meta/src/sql/studio/integrations/index.ts
Normal file
1
packages/pg-meta/src/sql/studio/integrations/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './enable-webhooks'
|
||||
Reference in New Issue
Block a user