From 514b09702113fd37cb836360c27fd736d29a6c04 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 30 Mar 2026 19:39:49 +0800 Subject: [PATCH] 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 --- .../Integration/IntegrationOverviewTab.tsx | 4 +- .../InstallIntegrationSheet.tsx | 288 ++++++++++-------- .../Landing/Integrations.constants.tsx | 16 +- .../Integrations/Webhooks/OverviewTab.tsx | 74 +++-- .../integrations/webhooks/overview.md | 4 +- packages/pg-meta/src/index.ts | 1 + .../studio/integrations/enable-webhooks.ts | 261 ++++++++++++++++ .../src/sql/studio/integrations/index.ts | 1 + 8 files changed, 484 insertions(+), 165 deletions(-) create mode 100644 packages/pg-meta/src/sql/studio/integrations/enable-webhooks.ts create mode 100644 packages/pg-meta/src/sql/studio/integrations/index.ts diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx index 9c94331bf9..580482a2ea 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx @@ -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) => { const { id } = useParams() const { data: project } = useSelectedProjectQuery() @@ -56,7 +58,7 @@ export const IntegrationOverviewTab = ({ - {dependsOnExtension && ( + {dependsOnExtension && !hideRequiredExtensionsSection && (

Required extensions

diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet.tsx index 2e404d7963..407adcc98b 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet.tsx @@ -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( - - ) + const toastId = toast.loading(`Installing ${name}`) try { - if (requiredExtensions.length > 0) { - toast.loading( - 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 ( @@ -164,10 +194,10 @@ export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheet >
- {integration.icon()} + {icon()}
- Install {integration.name} + Install {name} Review and configure this integration
@@ -181,7 +211,7 @@ export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheet

- {hasMissingExtensions && integration.missingExtensionsAlert} + {hasMissingExtensions && missingExtensionsAlert} @@ -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" /> @@ -245,95 +275,99 @@ export const InstallIntegrationSheet = ({ integration }: InstallIntegrationSheet - + {allowExtensionCustomSchema && ( + <> + - - - - - Advanced settings - - -

- Select which schemas to install the database extensions under -

- {requiredExtensionNames.map((extName) => { - const extMeta = extensionsSchema[extName as keyof typeof extensionsSchema] - const { schema, value } = extMeta - const recommendedSchema = extensionsWithRecommendedSchemas[extName] + + + + + Advanced settings + + +

+ Select which schemas to install the database extensions under +

+ {requiredExtensionNames.map((extName) => { + const extMeta = extensionsSchema[extName as keyof typeof extensionsSchema] + const { schema, value } = extMeta + const recommendedSchema = extensionsWithRecommendedSchemas[extName] - return ( - - - setExtensionsSchema((prev) => ({ - ...prev, - [extName]: { - schema, - value: schema === 'custom' ? extName : undefined, - }, - })) - } - > - - - - - - Create a new schema - - - {availableSchemas.map((schema) => { - return ( - - {schema.name} - {schema.name === recommendedSchema ? ( - - Recommended - - ) : schema.name === 'extensions' ? ( - Default - ) : null} - - ) - })} - - - - {schema === 'custom' && ( + return ( - + 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" - /> + > + + + + + + Create a new schema + + + {availableSchemas.map((schema) => { + return ( + + {schema.name} + {schema.name === recommendedSchema ? ( + + Recommended + + ) : schema.name === 'extensions' ? ( + Default + ) : null} + + ) + })} + + + + {schema === 'custom' && ( + + + setExtensionsSchema((prev) => ({ + ...prev, + [extName]: { + schema: prev[extName].schema, + value: e.target.value, + }, + })) + } + placeholder="Provide a name for your schema" + /> + + )} - )} - - ) - })} -
-
-
-
+ ) + })} +
+
+
+
+ + )} diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index eb4a504dd9..b184d0beb6 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -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 } & ( | { type: 'wrapper'; meta: WrapperMeta } | { type: 'postgres_extension' | 'custom' | 'oauth' | 'template' } @@ -230,7 +238,7 @@ const SUPABASE_INTEGRATIONS: Array = [ '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 = [ } return null }, + installationSql: getEnableWebhooksSQL(), + installationCommand: async ({ ref }: { ref: string }) => { + const queryClient = getQueryClient() + await enableDatabaseWebhooks({ ref }) + await invalidateSchemasQuery(queryClient, ref) + }, }, { id: 'data_api', diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx index be57417d29..bf2d1c1b88 100644 --- a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx @@ -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 ( - -

- Database Webhooks can be used to trigger serverless functions or send requests to an - HTTP endpoint -

- enableHooksForProject()} - disabled={isEnablingHooks} - tooltip={{ - content: { - side: 'bottom', - text: !canReadWebhooks - ? 'You need additional permissions to enable webhooks' - : undefined, - }, - }} + if (isMarketplaceEnabled) { + return + } else { + return ( + - Enable webhooks - - - ) - } - /> - ) +

+ Database Webhooks can be used to trigger serverless functions or send requests to an + HTTP endpoint +

+ enableHooksForProject()} + disabled={isEnablingHooks} + tooltip={{ + content: { + side: 'bottom', + text: !canReadWebhooks + ? 'You need additional permissions to enable webhooks' + : undefined, + }, + }} + > + Enable webhooks + + + ) + } + /> + ) + } } diff --git a/apps/studio/static-data/integrations/webhooks/overview.md b/apps/studio/static-data/integrations/webhooks/overview.md index b7d1f308a9..e2a5ddba5b 100644 --- a/apps/studio/static-data/integrations/webhooks/overview.md +++ b/apps/studio/static-data/integrations/webhooks/overview.md @@ -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. diff --git a/packages/pg-meta/src/index.ts b/packages/pg-meta/src/index.ts index aaab8e5355..0e498b0e93 100644 --- a/packages/pg-meta/src/index.ts +++ b/packages/pg-meta/src/index.ts @@ -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, diff --git a/packages/pg-meta/src/sql/studio/integrations/enable-webhooks.ts b/packages/pg-meta/src/sql/studio/integrations/enable-webhooks.ts new file mode 100644 index 0000000000..8197ea36ce --- /dev/null +++ b/packages/pg-meta/src/sql/studio/integrations/enable-webhooks.ts @@ -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() diff --git a/packages/pg-meta/src/sql/studio/integrations/index.ts b/packages/pg-meta/src/sql/studio/integrations/index.ts new file mode 100644 index 0000000000..2e2850e5ae --- /dev/null +++ b/packages/pg-meta/src/sql/studio/integrations/index.ts @@ -0,0 +1 @@ +export * from './enable-webhooks'