From fc609470c4db9bf4b88ac686ff5cbbc7c645239b Mon Sep 17 00:00:00 2001 From: Dan <8297864+D-K-P@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:36:03 +0100 Subject: [PATCH] Supabase database webhook example upgrade (#1386) * Added overview for guides and examples section and split them all out * New supabase guide wip * Updated images and improved docs * Trimmed the supabase prereqs * Supabase guide wip * more updates * Replaced old database webhook guide * Created one intro page and removed snippets * Updated guide sidebar titles * Code updates * More improvements * Updates and added images * Compressed image * Updated guides descriptions and edge function basic * Removed bold * Updated redirects * Fixed broken links * Updated intro --- docs/guides/frameworks/introduction.mdx | 20 - docs/guides/frameworks/prisma.mdx | 2 +- docs/guides/frameworks/sequin.mdx | 215 ++++---- .../supabase-edge-functions-basic.mdx | 1 + ...abase-edge-functions-database-webhooks.mdx | 479 ++++++++++++------ .../{examples/intro.mdx => introduction.mdx} | 34 +- docs/images/supabase-create-webhook-2.png | Bin 59889 -> 76849 bytes docs/images/supabase-create-webhook-3.png | Bin 55890 -> 72637 bytes docs/images/supabase-new-table-2.png | Bin 52458 -> 140583 bytes docs/images/supabase-new-table-3.png | Bin 36996 -> 37818 bytes docs/images/supabase-new-table-4.png | Bin 40036 -> 104338 bytes docs/images/supabase-run-result.png | Bin 0 -> 159107 bytes docs/images/supabase-table-result.png | Bin 0 -> 78061 bytes docs/images/supabase-trigger-screenshot.png | Bin 38167 -> 38340 bytes docs/introduction.mdx | 12 +- docs/mint.json | 95 ++-- docs/snippets/supabase-docs-cards.mdx | 8 +- docs/snippets/supabase-prerequisites.mdx | 26 +- 18 files changed, 526 insertions(+), 366 deletions(-) delete mode 100644 docs/guides/frameworks/introduction.mdx rename docs/guides/{examples/intro.mdx => introduction.mdx} (61%) create mode 100644 docs/images/supabase-run-result.png create mode 100644 docs/images/supabase-table-result.png diff --git a/docs/guides/frameworks/introduction.mdx b/docs/guides/frameworks/introduction.mdx deleted file mode 100644 index eb74771e7..000000000 --- a/docs/guides/frameworks/introduction.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: "Introduction" -sidebarTitle: "Introduction" -description: "Get started with Trigger.dev in your favorite framework." -icon: "grid-2" ---- - -import CardBun from "/snippets/card-bun.mdx"; -import CardNodejs from "/snippets/card-nodejs.mdx"; -import CardNextjs from "/snippets/card-nextjs.mdx"; -import CardRemix from "/snippets/card-remix.mdx"; -import CardSupabase from "/snippets/card-supabase.mdx"; - - - - - - - - diff --git a/docs/guides/frameworks/prisma.mdx b/docs/guides/frameworks/prisma.mdx index fbe495b3d..c337d06c2 100644 --- a/docs/guides/frameworks/prisma.mdx +++ b/docs/guides/frameworks/prisma.mdx @@ -1,6 +1,6 @@ --- title: "Prisma setup guide" -sidebarTitle: "Prisma" +sidebarTitle: "Prisma setup guide" description: "This guide will show you how to setup Prisma with Trigger.dev" icon: "Triangle" --- diff --git a/docs/guides/frameworks/sequin.mdx b/docs/guides/frameworks/sequin.mdx index 40abec048..986cd6f13 100644 --- a/docs/guides/frameworks/sequin.mdx +++ b/docs/guides/frameworks/sequin.mdx @@ -1,6 +1,6 @@ --- title: "Sequin database triggers" -sidebarTitle: "Sequin" +sidebarTitle: "Sequin database triggers" description: "This guide will show you how to trigger tasks from database changes using Sequin" icon: "database" --- @@ -22,9 +22,11 @@ As long as you create an HTTP endpoint that Sequin can deliver webhooks to, you You'll need the following to follow this guide: - A Next.js project with [Trigger.dev](https://trigger.dev) installed - -If you don't have one already, follow [Trigger.dev's Next.js setup guide](/guides/frameworks/nextjs) to setup your project. You can return to this guide when you're ready to write your first Trigger.dev task. - + + If you don't have one already, follow [Trigger.dev's Next.js setup + guide](/guides/frameworks/nextjs) to setup your project. You can return to this guide when + you're ready to write your first Trigger.dev task. + - A [Sequin](https://console.sequinstream.com/register) account - A Postgres database (Sequin works with any Postgres database version 12 and up) with a `posts` table. @@ -42,36 +44,36 @@ Start by creating a new Trigger.dev task that takes in a Sequin change event as import { OpenAI } from "openai"; import { upsertEmbedding } from "../util"; - const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, - }); +const openai = new OpenAI({ +apiKey: process.env.OPENAI_API_KEY, +}); - export const createEmbeddingForPost = task({ - id: "create-embedding-for-post", - run: async (payload: { - record: { - id: number; - title: string; - body: string; - author: string; - createdAt: string; - embedding: string | null; - }, - metadata: { - table_schema: string, - table_name: string, - consumer: { - id: string; - name: string; - }; - }; - }) => { - // Create an embedding using the title and body of payload.record - const content = `${payload.record.title}\n\n${payload.record.body}`; - const embedding = (await openai.embeddings.create({ - model: "text-embedding-ada-002", - input: content, - })).data[0].embedding; +export const createEmbeddingForPost = task({ +id: "create-embedding-for-post", +run: async (payload: { +record: { +id: number; +title: string; +body: string; +author: string; +createdAt: string; +embedding: string | null; +}, +metadata: { +table_schema: string, +table_name: string, +consumer: { +id: string; +name: string; +}; +}; +}) => { +// Create an embedding using the title and body of payload.record +const content = `${payload.record.title}\n\n${payload.record.body}`; +const embedding = (await openai.embeddings.create({ +model: "text-embedding-ada-002", +input: content, +})).data[0].embedding; // Upsert the embedding in the database. See utils.ts for the implementation -> -> await upsertEmbedding(embedding, payload.record.id); @@ -82,51 +84,55 @@ Start by creating a new Trigger.dev task that takes in a Sequin change event as embedding: JSON.stringify(embedding), }; } + +}); + +```` + +```ts utils.ts +import pg from "pg"; + +export async function upsertEmbedding(embedding: number[], id: number) { + const client = new pg.Client({ + connectionString: process.env.DATABASE_URL, }); - ``` + await client.connect(); - ```ts utils.ts - import pg from "pg"; + try { + const query = ` + INSERT INTO post_embeddings (id, embedding) + VALUES ($2, $1) + ON CONFLICT (id) + DO UPDATE SET embedding = $1 + `; + const values = [JSON.stringify(embedding), id]; - export async function upsertEmbedding(embedding: number[], id: number) { - const client = new pg.Client({ - connectionString: process.env.DATABASE_URL, - }); - await client.connect(); + const result = await client.query(query, values); + console.log(`Updated record in database. Rows affected: ${result.rowCount}`); - try { - const query = ` - INSERT INTO post_embeddings (id, embedding) - VALUES ($2, $1) - ON CONFLICT (id) - DO UPDATE SET embedding = $1 - `; - const values = [JSON.stringify(embedding), id]; - - const result = await client.query(query, values); - console.log(`Updated record in database. Rows affected: ${result.rowCount}`); - - return result.rowCount; - } catch (error) { - console.error("Error updating record in database:", error); - throw error; - } finally { - await client.end(); - } + return result.rowCount; + } catch (error) { + console.error("Error updating record in database:", error); + throw error; + } finally { + await client.end(); } - ``` +} +```` + - This task takes in a Sequin record event, creates an embedding, and then upserts the embedding into a `post_embeddings` table. +This task takes in a Sequin record event, creates an embedding, and then upserts the embedding into a `post_embeddings` table. + Register the `create-embedding-for-post` task to your Trigger.dev cloud project by running the following command: - ```bash - npx trigger.dev@latest dev - ``` +```bash +npx trigger.dev@latest dev +``` - In the Trigger.dev dashboard, you should now see the `create-embedding-for-post` task: +In the Trigger.dev dashboard, you should now see the `create-embedding-for-post` task: Task added @@ -135,7 +141,8 @@ Start by creating a new Trigger.dev task that takes in a Sequin change event as - You've successfully created a Trigger.dev task that will create an embedding for each post in your database. In the next step, you'll create an API endpoint that Sequin can deliver records to. + You've successfully created a Trigger.dev task that will create an embedding for each post in your + database. In the next step, you'll create an API endpoint that Sequin can deliver records to. ## Setup API route @@ -143,55 +150,60 @@ Start by creating a new Trigger.dev task that takes in a Sequin change event as You'll now create an API endpoint that will receive posts from Sequin and then trigger the `create-embedding-for-post` task. -This guide covers how to setup an API endpoint using the Next.js App Router. You can find examples for Next.js Server Actions and Pages Router in the [Trigger.dev documentation](https://trigger.dev/docs/guides/frameworks/nextjs). + This guide covers how to setup an API endpoint using the Next.js App Router. You can find examples + for Next.js Server Actions and Pages Router in the [Trigger.dev + documentation](https://trigger.dev/docs/guides/frameworks/nextjs). Add a route handler by creating a new `route.ts` file in a `/app/api/create-embedding-for-post` directory: - ```ts app/api/create-embedding-for-post/route.ts - import type { createEmbeddingForPost } from "@/trigger/create-embedding-for-post"; - import { tasks } from "@trigger.dev/sdk/v3"; - import { NextResponse } from "next/server"; +```ts app/api/create-embedding-for-post/route.ts +import type { createEmbeddingForPost } from "@/trigger/create-embedding-for-post"; +import { tasks } from "@trigger.dev/sdk/v3"; +import { NextResponse } from "next/server"; - export async function POST(req: Request) { - const authHeader = req.headers.get('authorization'); - if (!authHeader || authHeader !== `Bearer ${process.env.SEQUIN_WEBHOOK_SECRET}`) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - const payload = await req.json(); - const handle = await tasks.trigger( - "create-embedding-for-post", - payload - ); - - return NextResponse.json(handle); +export async function POST(req: Request) { + const authHeader = req.headers.get("authorization"); + if (!authHeader || authHeader !== `Bearer ${process.env.SEQUIN_WEBHOOK_SECRET}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - ``` + const payload = await req.json(); + const handle = await tasks.trigger( + "create-embedding-for-post", + payload + ); + + return NextResponse.json(handle); +} +``` + +This route handler will receive records from Sequin, parse them, and then trigger the `create-embedding-for-post` task. - This route handler will receive records from Sequin, parse them, and then trigger the `create-embedding-for-post` task. You'll need to set four secret keys in a `.env.local` file: - ```bash - SEQUIN_WEBHOOK_SECRET=your-secret-key - TRIGGER_SECRET_KEY=secret-from-trigger-dev - OPENAI_API_KEY=sk-proj-asdfasdfasdf - DATABASE_URL=postgresql:// - ``` +```bash +SEQUIN_WEBHOOK_SECRET=your-secret-key +TRIGGER_SECRET_KEY=secret-from-trigger-dev +OPENAI_API_KEY=sk-proj-asdfasdfasdf +DATABASE_URL=postgresql:// +``` - The `SEQUIN_WEBHOOK_SECRET` ensures that only Sequin can access your API endpoint. +The `SEQUIN_WEBHOOK_SECRET` ensures that only Sequin can access your API endpoint. - The `TRIGGER_SECRET_KEY` is used to authenticate requests to Trigger.dev and can be found in the **API keys** tab of the Trigger.dev dashboard. +The `TRIGGER_SECRET_KEY` is used to authenticate requests to Trigger.dev and can be found in the **API keys** tab of the Trigger.dev dashboard. + +The `OPENAI_API_KEY` and `DATABASE_URL` are used to create an embedding using OpenAI and connect to your database. Be sure to add these as [environment variables](https://trigger.dev/docs/deploy-environment-variables) in Trigger.dev as well. - The `OPENAI_API_KEY` and `DATABASE_URL` are used to create an embedding using OpenAI and connect to your database. Be sure to add these as [environment variables](https://trigger.dev/docs/deploy-environment-variables) in Trigger.dev as well. - You've successfully created an API endpoint that can receive record payloads from Sequin and trigger a Trigger.dev task. In the next step, you'll setup Sequin to trigger the endpoint. + You've successfully created an API endpoint that can receive record payloads from Sequin and + trigger a Trigger.dev task. In the next step, you'll setup Sequin to trigger the endpoint. ## Create Sequin consumer @@ -253,11 +265,10 @@ You'll now configure Sequin to send every row in your `posts` table to your Trig 7. Click the **Create Consumer** button. + - - Your Sequin consumer is now created and ready to send events to your API endpoint. - +Your Sequin consumer is now created and ready to send events to your API endpoint. ## Test end-to-end @@ -301,10 +312,12 @@ You'll now configure Sequin to send every row in your `posts` table to your Trig Task run + - Every time a post is created or updated, Sequin will deliver the row payload to your API endpoint and Trigger.dev will run the `create-embedding-for-post` task. + Every time a post is created or updated, Sequin will deliver the row payload to your API endpoint + and Trigger.dev will run the `create-embedding-for-post` task. ## Next steps @@ -314,4 +327,4 @@ With Sequin and Trigger.dev, every post in your database will now have an embedd From here, add error handling and deploy to production: - Add [retries](/errors-retrying) to your Trigger.dev task to ensure that any errors are captured and logged. -- Deploy to [production](/guides/frameworks/nextjs#deploying-your-task-to-trigger-dev) and update your Sequin consumer to point to your production database and endpoint. \ No newline at end of file +- Deploy to [production](/guides/frameworks/nextjs#deploying-your-task-to-trigger-dev) and update your Sequin consumer to point to your production database and endpoint. diff --git a/docs/guides/frameworks/supabase-edge-functions-basic.mdx b/docs/guides/frameworks/supabase-edge-functions-basic.mdx index 9cf274948..fecd5f0eb 100644 --- a/docs/guides/frameworks/supabase-edge-functions-basic.mdx +++ b/docs/guides/frameworks/supabase-edge-functions-basic.mdx @@ -26,6 +26,7 @@ This guide shows you how to set up and deploy a simple Supabase edge function ex ## Prerequisites - Ensure you have the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) installed +- Since Supabase CLI version 1.123.4, you must have [Docker Desktop installed](https://supabase.com/docs/guides/functions/deploy#deploy-your-edge-functions) to deploy Edge Functions - Ensure TypeScript is installed - [Create a Trigger.dev account](https://cloud.trigger.dev) - [Create a new Trigger.dev project](/guides/dashboard/creating-a-project) diff --git a/docs/guides/frameworks/supabase-edge-functions-database-webhooks.mdx b/docs/guides/frameworks/supabase-edge-functions-database-webhooks.mdx index a9986a576..1060c419d 100644 --- a/docs/guides/frameworks/supabase-edge-functions-database-webhooks.mdx +++ b/docs/guides/frameworks/supabase-edge-functions-database-webhooks.mdx @@ -1,15 +1,11 @@ --- -title: "Triggering tasks from Supabase database webhooks" +title: "Triggering tasks from Supabase Database Webhooks" sidebarTitle: "Database webhooks" -description: "This guide will show you how to trigger a task when a row is added to a table using a Supabase database webhook and edge function." +description: "This guide shows you how to trigger a transcribing task when a row is added to a table in a Supabase database, using a Database Webhook and Edge Function." --- import Prerequisites from "/snippets/framework-prerequisites.mdx"; import SupabasePrerequisites from "/snippets/supabase-prerequisites.mdx"; -import CliInitStep from "/snippets/step-cli-init.mdx"; -import CliDevStep from "/snippets/step-cli-dev.mdx"; -import CliRunTestStep from "/snippets/step-run-test.mdx"; -import CliViewRunStep from "/snippets/step-view-run.mdx"; import UsefulNextSteps from "/snippets/useful-next-steps.mdx"; import TriggerTaskNextjs from "/snippets/trigger-tasks-nextjs.mdx"; import NextjsTroubleshootingMissingApiKey from "/snippets/nextjs-missing-api-key.mdx"; @@ -19,174 +15,260 @@ import SupabaseDocsCards from "/snippets/supabase-docs-cards.mdx"; ## Overview -Database webhooks allow you to send realtime data from your database to another system whenever an event occurs in your table e.g. when a row is inserted, updated, or deleted, or when a specific column is updated. +Supabase and Trigger.dev can be used together to create powerful workflows triggered by real-time changes in your database tables: -This guide shows you how to set up a Supabase database webhook and deploy a simple edge function that triggers a "Hello world" task every time a new row is inserted into your table. +- A Supabase Database Webhook triggers an Edge Function when a row including a video URL is inserted into a table +- The Edge Function triggers a Trigger.dev task, passing the `video_url` column data from the new table row as the payload +- The Trigger.dev task then: + - Uses [FFmpeg](https://www.ffmpeg.org/) to extract the audio track from a video URL + - Uses [Deepgram](https://deepgram.com) to transcribe the extracted audio + - Updates the original table row using the `record.id` in Supabase with the new transcription using `update` ## Prerequisites - Ensure you have the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) installed +- Since Supabase CLI version 1.123.4, you must have [Docker Desktop installed](https://supabase.com/docs/guides/functions/deploy#deploy-your-edge-functions) to deploy Edge Functions - Ensure TypeScript is installed - [Create a Trigger.dev account](https://cloud.trigger.dev) - [Create a new Trigger.dev project](/guides/dashboard/creating-a-project) +- [Create a new Deepgram account](https://deepgram.com/) and get your API key from the dashboard ## Initial setup - - - - - + -## Create and deploy a new Supabase edge function and create a new database webhook +The easiest way to get started is to use the CLI. It will add Trigger.dev to your existing project, create a `/trigger` folder and give you an example task. - +Run this command in the root of your project to get started: - + -First, go to your Trigger.dev [project dashboard](https://cloud.trigger.dev) and copy the `prod` secret key from the API keys page. - -![How to find your prod secret key](/images/api-key-prod.png) - -Then, in [Supabase](https://supabase.com/dashboard/projects), select the project you want to use, navigate to 'Project settings' , click 'Edge functions' in the configurations menu, and then click the 'Add new secret' button. - -Add `TRIGGER_SECRET_KEY` with the pasted value of your Trigger.dev `prod` secret key. - -![Add secret key in Supabase](/images/supabase-keys-1.png) - - - - - -Now create a new edge function using the Supabase CLI. We will call it `database-webhook`. - -```bash -supabase functions new database-webhook +```bash npm +npx trigger.dev@latest init ``` +```bash pnpm +pnpm dlx trigger.dev@latest init +``` + +```bash yarn +yarn dlx trigger.dev@latest init +``` + + + +It will do a few things: + +1. Log you into the CLI if you're not already logged in. +2. Create a `trigger.config.ts` file in the root of your project. +3. Ask where you'd like to create the `/trigger` directory. +4. Create the `/trigger` directory with an example task, `/trigger/example.[ts/js]`. + +Choose "None" when prompted to install an example task. We will create a new task for this guide. + - + -Replace the `database-webhook` placeholder code with the following code: +## Create a new table in your Supabase database -```ts functions/database-webhook/index.ts -import "jsr:@supabase/functions-js/edge-runtime.d.ts"; -// Import the Trigger.dev SDK - replace "" with the version of the SDK you are using, e.g. "3.0.0". You can find this in your package.json file. -import { tasks } from "npm:@trigger.dev/sdk@3.0.0/v3"; -// Import your task type from your /trigger folder -import type { helloWorldTask } from "../../../src/trigger/example.ts"; -// 👆 **type-only** import +First, in the Supabase project dashboard, you'll need to create a new table to store the video URL and transcription. -console.log("Hello from 'database-webhook' function!"); +To do this, click on 'Table Editor' in the left-hand menu and create a new table. -Deno.serve(async (req) => { - // Listens for incoming JSON requests - const payload = await req.json(); - // Triggers the "hello-world" task with the payload - await tasks.trigger( - // Your task id - "hello-world", - // Your task payload. This will be the data you receive from the database webhook - payload - ); - return new Response("ok"); +![How to create a new Supabase table](/images/supabase-new-table-1.png) + +Call your table `video_transcriptions`. + +Add two new columns, one called `video_url` with the type `text` , and another called `transcription`, also with the type `text` . + +![How to create a new Supabase table 2](/images/supabase-new-table-2.png) + +## Create and deploy the Trigger.dev task + +### Generate the Database type definitions + +To allow you to use TypeScript to interact with your table, you need to [generate the type definitions](https://supabase.com/docs/guides/api/rest/generating-types) for your Supabase table using the Supabase CLI. + +```bash +supabase gen types --lang=typescript --project-id --schema public > database.types.ts +``` + + Replace `` with your Supabase project reference ID. This can be found in your Supabase project settings under 'General'. + +### Create the transcription task + +Create a new task file in your `/trigger` folder. Call it `videoProcessAndUpdate.ts`. + +This task takes a video from a public video url, extracts the audio using FFmpeg and transcribes the audio using Deepgram. The transcription summary will then be updated back to the original row in the `video_transcriptions` table in Supabase. + +You will need to install some additional dependencies for this task: + + + +```bash npm +npm install @deepgram/sdk @supabase/supabase-js fluent-ffmpeg +``` + +```bash pnpm +pnpm install @deepgram/sdk @supabase/supabase-js fluent-ffmpeg +``` + +```bash yarn +yarn install @deepgram/sdk @supabase/supabase-js fluent-ffmpeg +``` + + + +These dependencies will allow you to interact with the Deepgram and Supabase APIs and extract audio from a video using FFmpeg. + +```ts /trigger/videoProcessAndUpdate.ts +// Install any missing dependencies below +import { createClient as createDeepgramClient } from "@deepgram/sdk"; +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; +import { logger, task } from "@trigger.dev/sdk/v3"; +import ffmpeg from "fluent-ffmpeg"; +import fs from "fs"; +import { Readable } from "node:stream"; +import os from "os"; +import path from "path"; +import { Database } from "../../database.types"; + +// Create a single Supabase client for interacting with your database +// 'Database' supplies the type definitions to supabase-js +const supabase = createSupabaseClient( + // These details can be found in your Supabase project settings under `API` + process.env.SUPABASE_PROJECT_URL as string, // e.g. https://abc123.supabase.co - replace 'abc123' with your project ID + process.env.SUPABASE_SERVICE_ROLE_KEY as string // Your service role secret key +); + +// Your DEEPGRAM_SECRET_KEY can be found in your Deepgram dashboard +const deepgram = createDeepgramClient(process.env.DEEPGRAM_SECRET_KEY); + +export const videoProcessAndUpdate = task({ + id: "video-process-and-update", + run: async (payload: { videoUrl: string; id: number }) => { + const { videoUrl, id } = payload; + + logger.log(`Processing video at URL: ${videoUrl}`); + + // Generate temporary file names + const tempDirectory = os.tmpdir(); + const outputPath = path.join(tempDirectory, `audio_${Date.now()}.wav`); + + // Fetch the video + const response = await fetch(videoUrl); + + // Extract the audio + await new Promise((resolve, reject) => { + if (!response.body) { + return reject(new Error("Failed to fetch video")); + } + + ffmpeg(Readable.from(response.body)) + .outputOptions([ + "-vn", // Disable video output + "-acodec pcm_s16le", // Use PCM 16-bit little-endian encoding + "-ar 44100", // Set audio sample rate to 44.1 kHz + "-ac 2", // Set audio channels to stereo + ]) + .output(outputPath) + .on("end", resolve) + .on("error", reject) + .run(); + }); + + logger.log(`Audio extracted from video`, { outputPath }); + + // Transcribe the audio using Deepgram + const { result, error } = await deepgram.listen.prerecorded.transcribeFile( + fs.readFileSync(outputPath), + { + model: "nova-2", // Use the Nova 2 model + smart_format: true, // Automatically format the transcription + diarize: true, // Enable speaker diarization + } + ); + + if (error) { + throw error; + } + + // Convert the result object to a string + const transcription = result.results.channels[0].alternatives[0].paragraphs?.transcript; + + logger.log(`Transcription: ${transcription}`); + + // Delete the temporary audio file + fs.unlinkSync(outputPath); + logger.log(`Temporary audio file deleted`, { outputPath }); + + const { error: updateError } = await supabase + .from("video_transcriptions") + // Set the plan to the new plan and update the timestamp + .update({ transcription: transcription, video_url: videoUrl }) + // Find the row by its ID + .eq("id", id); + + if (updateError) { + throw new Error(`Failed to update transcription: ${updateError.message}`); + } + + return { + message: `Summary of the audio: ${transcription}`, + result, + }; + }, }); ``` -This code sets up a Deno server that listens for incoming JSON requests, triggers a "hello-world" task, logs the received payload, and responds with "ok". This setup is typical for a webhook endpoint that processes incoming data and triggers some action (in this case, the "hello-world" task) based on that data. + + When updating your tables from a Trigger.dev task which has been triggered by a database change, + be extremely careful to not cause an infinite loop. Ensure you have the correct conditions in + place to prevent this. + -You can only import the `type` from the task. - - Tasks in the `trigger` folder use Node, so they must stay in there or they will not run, - especially if you are using a different runtime like Deno. Also do not add "`npm:`" to imports - inside your task files, for the same reason. - +### Adding the FFmpeg build extension - +Before you can deploy the task, you'll need to add the FFmpeg build extension to your `trigger.config.ts` file. - +```ts trigger.config.ts +// Add this import +import { ffmpeg } from "@trigger.dev/build/extensions/core"; +import { defineConfig } from "@trigger.dev/sdk/v3"; -Now deploy your edge function with the following command: - -```bash -supabase functions deploy database-webhook +export default defineConfig({ + project: "", // Replace with your project ref + // Your other config settings... + build: { + // Add the FFmpeg build extension + extensions: [ffmpeg()], + }, +}); ``` -Follow the CLI instructions, selecting the same project you added your `prod` secret key to, and once complete you should see your new edge function deployment in your Supabase edge functions dashboard. + + [Build extensions](/config/config-file#extensions) allow you to hook into the build system and + customize the build process or the resulting bundle and container image (in the case of + deploying). You can use pre-built extensions or create your own. + -There will be a link to the dashboard in your terminal output, or you can find it at this URL: + + You'll also need to add `@trigger.dev/build` to your `package.json` file under `devDependencies` + if you don't already have it there. + -`https://supabase.com/dashboard/project//functions` +### Add your Deepgram and Supabase environment variables to your Trigger.dev project -Replace `your-project-id` with your actual project ID. +You will need to add your `DEEPGRAM_SECRET_KEY`, `SUPABASE_PROJECT_URL` and `SUPABASE_SERVICE_ROLE_KEY` as environment variables in your Trigger.dev project. This can be done in the 'Environment Variables' page in your project dashboard. - +![Adding environment variables](/images/environment-variables-page.jpg) - +### Deploying your task -Next, in your Supabase project dashboard, click on 'Table Editor' in the left-hand menu and create a new table. - -![How to create a new table](/images/supabase-new-table-1.png) - -In this example we will call our table `skynet`. - -Add a new column called `name` with the type `text`. - -![How to add a new column](/images/supabase-new-table-2.png) - - - - - -By default, Supabase edge functions require a JSON Web Token [JWT](https://supabase.com/docs/guides/auth/jwts) in the authorization header. This is to ensure that only authorized users can access your edge functions. - -In your Supabase project dashboard, click 'Project settings' , then the 'API' tab , and copy the `anon` `public` API key from the table . - -![How to find your Supabase API keys](/images/supabase-api-key.png) - -Then, go to 'Database' click on 'Webhooks' , and then click 'Create a new hook' . - -![How to create a new webhook](/images/supabase-create-webhook-1.png) - - Call the hook `edge-function-hook`. - - Select the new table you have created: -`public` `skynet`. - - Choose the `insert` event. - -![How to create a new webhook 2](/images/supabase-create-webhook-2.png) - - Under 'Webhook configuration', select -'Supabase Edge functions'{" "} - - Under 'Edge function', choose `POST` -and select the edge function you have created: `database-webhook`.{" "} - - Under 'HTTP Headers', add a new header with the key `Authorization` and the value `Bearer ` (replace `` with the `anon` `public` API key you copied earlier). - - Click 'Create webhook'.{" "} - -![How to create a new webhook 3](/images/supabase-create-webhook-3.png) - -Your database webhook is now ready to use. - - - - - -## Deploy your task and trigger it from your new `database-webhook` edge function - - - - - -The next step is to deploy your `hello-world` task to [Trigger.dev cloud](https://cloud.trigger.dev). - -To do this, run the following command in the terminal: +Now you can now deploy your task using the following command: @@ -204,34 +286,131 @@ yarn dlx trigger.dev@latest deploy - +## Create and deploy the Supabase Edge Function - +### Add your Trigger.dev prod secret key to the Supabase dashboard -Your `database-webhook` edge function is now set up to trigger the `hello-world` task every time a new row is inserted into your `skynet` table. +Go to your Trigger.dev [project dashboard](https://cloud.trigger.dev) and copy the `prod` secret key from the API keys page. -To do this, go back to your Supabase project dashboard, click on 'Table Editor' in the left-hand menu, click on the `skynet` table , and then click 'Insert', 'Insert Row' . +![How to find your prod secret key](/images/api-key-prod.png) + +Then, in [Supabase](https://supabase.com/dashboard/projects), select the project you want to use, navigate to 'Project settings' , click 'Edge Functions' in the configurations menu, and then click the 'Add new secret' button. + +Add `TRIGGER_SECRET_KEY` with the pasted value of your Trigger.dev `prod` secret key. + +![Add secret key in Supabase](/images/supabase-keys-1.png) + +### Create a new Edge Function using the Supabase CLI + +Now create an Edge Function using the Supabase CLI. Call it `video-processing-handler`. This function will be triggered by the Database Webhook. + +```bash +supabase functions new video-processing-handler +``` + +```ts functions/video-processing-handler/index.ts +// Setup type definitions for built-in Supabase Runtime APIs +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { tasks } from "npm:@trigger.dev/sdk@latest/v3"; +// Import the videoProcessAndUpdate task from the trigger folder +import type { videoProcessAndUpdate } from "../../../src/trigger/videoProcessAndUpdate.ts"; +// 👆 type only import + +// Sets up a Deno server that listens for incoming JSON requests +Deno.serve(async (req) => { + const payload = await req.json(); + + // This payload will contain the video url and id from the new row in the table + const videoUrl = payload.record.video_url; + const id = payload.record.id; + + // Trigger the videoProcessAndUpdate task with the videoUrl payload + await tasks.trigger("video-process-and-update", { videoUrl, id }); + console.log(payload ?? "No name provided"); + + return new Response("ok"); +}); +``` + + + Tasks in the `trigger` folder use Node, so they must stay in there or they will not run, + especially if you are using a different runtime like Deno. Also do not add "`npm:`" to imports + inside your task files, for the same reason. + + +### Deploy the Edge Function + +Now deploy your new Edge Function with the following command: + +```bash +supabase functions deploy video-processing-handler +``` + +Follow the CLI instructions, selecting the same project you added your `prod` secret key to, and once complete you should see your new Edge Function deployment in your Supabase Edge Functions dashboard. + +There will be a link to the dashboard in your terminal output. + +## Create the Database Webhook + +In your Supabase project dashboard, click 'Project settings' , then the 'API' tab , and copy the `anon` `public` API key from the table . + +![How to find your Supabase API keys](/images/supabase-api-key.png) + +Then, go to 'Database' click on 'Webhooks' , and then click 'Create a new hook' . + +![How to create a new webhook](/images/supabase-create-webhook-1.png) + + Call the hook `edge-function-hook`. + + Select the new table you have created: +`public` `video_transcriptions`. + + Choose the `insert` event. + +![How to create a new webhook 2](/images/supabase-create-webhook-2.png) + + Under 'Webhook configuration', select +'Supabase Edge Functions'{" "} + + Under 'Edge Function', choose `POST` +and select the Edge Function you have created: `video-processing-handler`.{" "} + + Under 'HTTP Headers', add a new header with the key `Authorization` and the value `Bearer ` (replace `` with the `anon` `public` API key you copied earlier). + + + Supabase Edge Functions require a JSON Web Token [JWT](https://supabase.com/docs/guides/auth/jwts) + in the authorization header. This is to ensure that only authorized users can access your edge + functions. + + + Click 'Create webhook'.{" "} + +![How to create a new webhook 3](/images/supabase-create-webhook-3.png) + +Your Database Webhook is now ready to use. + +## Triggering the entire workflow + +Your `video-processing-handler` Edge Function is now set up to trigger the `videoProcessAndUpdate` task every time a new row is inserted into your `video_transcriptions` table. + +To do this, go back to your Supabase project dashboard, click on 'Table Editor' in the left-hand menu, click on the `video_transcriptions` table , and then click 'Insert', 'Insert Row' . ![How to insert a new row 1](/images/supabase-new-table-3.png) -Add a new item under `name`, with the value `Sarah Connor` (this can be any string). +Add a new item under `video_url`, with a public video url. . + +You can use the following public video URL for testing: `https://content.trigger.dev/Supabase%20Edge%20Functions%20Quickstart.mp4`. ![How to insert a new row 2](/images/supabase-new-table-4.png) -Go back to your edge function dashboard , and under 'Logs' you should see a new run of your `database-webhook` edge function. +Once the new table row has been inserted, check your [cloud.trigger.dev](http://cloud.trigger.dev) project 'Runs' list and you should see a processing `videoProcessAndUpdate` task which has been triggered when you added a new row with the video url to your `video_transcriptions` table. -![How to view the logs](/images/supabase-logs.png) +![Supabase successful run](/images/supabase-run-result.png) -Then, check your [cloud.trigger.dev](http://cloud.trigger.dev) project 'Runs' list and you should see a successful `hello-world` task which has been triggered when you added a new row with the `name` `Sarah Connor` to your `skynet` Supabase table. +Once the run has completed successfully, go back to your Supabase `video_transcriptions` table, and you should see that in the row containing the original video URL, the transcription has now been added to the `transcription` column. -Inside that run you will see the payload that was sent from the database webhook, including the `name` and other table information. +![Supabase successful table update](/images/supabase-table-result.png) -![How to insert a new row 2](/images/supabase-trigger-screenshot.png) - -**Congratulations, you have successfully triggered a task from a Supabase edge function using a database webhook!** - - - - +**Congratulations! You have completed the full workflow from Supabase to Trigger.dev and back again.** diff --git a/docs/guides/examples/intro.mdx b/docs/guides/introduction.mdx similarity index 61% rename from docs/guides/examples/intro.mdx rename to docs/guides/introduction.mdx index fe41b1f3b..7e41b7b85 100644 --- a/docs/guides/examples/intro.mdx +++ b/docs/guides/introduction.mdx @@ -1,12 +1,42 @@ --- -title: "Introduction" +title: "Frameworks, guides and examples overview" sidebarTitle: "Introduction" -description: "Learn how to use Trigger.dev with these practical task examples." +description: "An ever growing list of guides and examples to help you get setup with Trigger.dev." --- +import CardBun from "/snippets/card-bun.mdx"; +import CardNodejs from "/snippets/card-nodejs.mdx"; +import CardNextjs from "/snippets/card-nextjs.mdx"; +import CardRemix from "/snippets/card-remix.mdx"; +import CardSupabase from "/snippets/card-supabase.mdx"; + +## Frameworks + + + + + + + + + +## Guides + +Get set up fast using our detailed walk-through guides. + +| Guide | Description | +| :---------------------------------------------------- | :------------------------------------------------------------------------------- | +| [Prisma](/guides/frameworks/prisma) | This guide will show you how to setup Prisma with Trigger.dev | +| [Sequin database triggers](/guides/frameworks/sequin) | This guide will show you how to trigger tasks from database changes using Sequin | + +## Example tasks + +Tasks you can copy and paste to get started with Trigger.dev. They can all be extended and customized to fit your needs. + | Example task | Description | | :---------------------------------------------------------------------------- | :----------------------------------------------------------------------------- | | [DALL·E 3 image generation](/guides/examples/dall-e3-generate-image) | Use OpenAI's GPT-4o and DALL·E 3 to generate an image and text. | +| [Deepgram audio transcription](/guides/examples/deepgram-transcribe-audio) | Transcribe audio using Deepgram's speech recognition API. | | [FFmpeg video processing](/guides/examples/ffmpeg-video-processing) | Use FFmpeg to process a video in various ways and save it to Cloudflare R2. | | [OpenAI with retrying](/guides/examples/open-ai-with-retrying) | Create a reusable OpenAI task with custom retry options. | | [PDF to image](/guides/examples/pdf-to-image) | Use `MuPDF` to turn a PDF into images and save them to Cloudflare R2. | diff --git a/docs/images/supabase-create-webhook-2.png b/docs/images/supabase-create-webhook-2.png index 18ab7195c65a2c21e3a006fe9375b5b961ebc6b1..0c21b485ec9465105fa021ab6a5a963a0d32cbb8 100644 GIT binary patch literal 76849 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