- name
- resonate-supabase-deployments-typescript
- description
- Build Resonate workflows on Supabase Edge Functions (TypeScript/Deno) using the Supabase shim, start/probe endpoints, and optional DB progress tracking.
- license
- Apache-2.0
# Resonate Supabase TypeScript
## Overview
Build durable workflows on Supabase Edge Functions using Resonate, with start/probe endpoints and optional progress tracking in Supabase.
Related: [`resonate-server-postgres`](../resonate-server-postgres/SKILL.md) — the server these workers talk to. Use it for installing `resonate-pg`, the `pg_cron` timer dependency, `invoke`'s 24-hour default timeout, grants, retention, and the known gaps.
## Summary
- Build durable workflows on Supabase Edge Functions using the Resonate Supabase shim.
- Use when you need long-running, reliable, stepwise workflows triggered by HTTP or external events.
- Outputs a `flows/index.ts` workflow module plus start/probe endpoint usage patterns.
## Preconditions / Assumptions
- Supabase project with `supabase/functions` directory.
- Supabase Edge Functions runtime (Deno) is available.
- Resonate Server URL is known and reachable.
- Assumes a recent stable Resonate Server and `@resonatehq/supabase` shim.
## Inputs
- `RESONATE_URL`: Resonate Server URL (Supabase secret or env var).
- `SUPABASE_URL`: Supabase project URL (for DB access).
- `SUPABASE_SERVICE_ROLE_KEY`: Service key for server-side DB operations.
- Workflow name (function name to register).
- Execution ID scheme (uuid or domain id).
## Outputs
- `supabase/functions/flows/index.ts` with registered workflows and handler.
- HTTP endpoints for `start` and `probe` invocation.
- Optional DB table for progress tracking.
## CRITICAL: Architecture & Communication Direction
**The edge function initiates communication with the Resonate server, NOT vice versa.**
When triggered by an HTTP request, the edge function wakes up and polls the Resonate server for state. The shim handles all HTTP communication internally - you just write normal Resonate code.
```
HTTP Request → Edge Function wakes up → Polls Resonate Server → Executes workflow step → Returns
```
**The Resonate server does NOT call your edge function.** Your edge function calls the server.
## CRITICAL: Constructor & Handler Patterns
### Constructor - Reads from Environment Variables
```ts
// Option 1: Let shim read from env vars (RECOMMENDED)
const resonate = new Resonate();
// Reads RESONATE_URL and RESONATE_TOKEN automatically
// Option 2: Explicit configuration
const resonate = new Resonate({
url: Deno.env.get("RESONATE_URL")!,
token: Deno.env.get("RESONATE_TOKEN")
});
```
**Note:** The TypeScript types may show `{ verbose?, encryptor? }` - this is because url/token are read from env vars by default. Trust the skill documentation over type hints.
### Handler - Direct Call, NOT a Factory
```ts
// ✅ CORRECT - Direct call
Deno.serve((req) => resonate.handler(req));
// ❌ WRONG - Factory pattern (old/incorrect)
Deno.serve((req) => resonate.handler()(req));
```
## Minimal Complete Example
This is the simplest working pattern for a Supabase edge function with Resonate:
```ts
// supabase/functions/flows/index.ts
import { Resonate, Context } from "@resonatehq/supabase";
const resonate = new Resonate();
function* myWorkflow(ctx: Context, name: string): Generator<any, string, any> {
yield* ctx.run(() => console.log(`Hello, ${name}!`));
yield* ctx.sleep(1000);
return `Completed for ${name}`;
}
resonate.register("myWorkflow", myWorkflow);
Deno.serve((req) => resonate.handler(req));
```
**That's it.** No complex wrappers, no HTTP routing, no state management. The shim handles everything.
## Core concepts (minimal)
- Supabase Edge Functions are short-lived; Resonate makes them durable via checkpoints.
- The Resonate Supabase shim exposes `Resonate` and `Context` and provides `resonate.handler(req)`.
- `start` and `probe` endpoints manage top-level workflow execution.
- `context.id` is the execution id (passed via `start`), useful for DB correlation.
- Durable steps are all `yield*` calls on `Context`.
## Procedure
### 1) Confirm the Supabase function layout
Intent: keep entrypoints consistent with Resonate shim routing.
Ensure this structure exists:
```
supabase/
|-- config.toml
`-- functions/
|-- flows/
| |-- deno.json
| `-- index.ts
|-- probe/
| |-- deno.json
| `-- index.ts
`-- start/
|-- deno.json
`-- index.ts
```
Do not modify `start/` and `probe/` if they are provided by your template; only edit `flows/index.ts`.
### 2) Implement workflows in `flows/index.ts`
Intent: register durable functions and expose the Resonate handler.
```ts
// supabase/functions/flows/index.ts
import { Resonate, type Context } from "@resonatehq/supabase";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
// Initialize Resonate - reads RESONATE_URL from env by default
// For token auth, pass token explicitly or set RESONATE_TOKEN env var
const resonate = new Resonate({
url: Deno.env.get("RESONATE_URL")!,
token: Deno.env.get("RESONATE_TOKEN") // JWT token if auth required
});
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
resonate.setDependency("supabase", supabase);
function* processOrder(ctx: Context, orderId: string) {
const supabase = ctx.getDependency("supabase");
// persist progress
yield* ctx.run(async () => {
await supabase.from("order_progress").upsert({
id: ctx.id,
order_id: orderId,
status: "started",
updated_at: new Date().toISOString(),
});
});
const order = yield* ctx.run(loadOrder, orderId);
yield* ctx.run(async () => {
await supabase.from("order_progress").update({
status: "loaded",
updated_at: new Date().toISOString(),
}).eq("id", ctx.id);
});
// The SDK generates the promise id — create it first, then store the id so
// the approving client can find it.
const approval = yield* ctx.promise<boolean>();
yield* ctx.run(async () => {
await supabase.from("orders")
.update({ approval_promise_id: approval.id })
.eq("id", ctx.id);
});
const ok = yield* approval;
if (!ok) throw new Error("rejected");
return { orderId, status: "approved" };
}
async function loadOrder(_: Context, orderId: string) {
return { id: orderId };
}
resonate.register("processOrder", processOrder);
Deno.serve((req) => resonate.handler(req));
```
### 3) Trigger a workflow via `start`
Intent: start durable execution from your app or curl.
```ts
const response = await fetch(`${SUPABASE_URL}/functions/v1/start`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
},
body: JSON.stringify({
uuid: `order/${orderId}`,
func: "processOrder",
args: [orderId],
}),
});
const { uuid } = await response.json();
```
### 4) Check status via `probe`
Intent: poll or gate UI with execution state.
```ts
const response = await fetch(`${SUPABASE_URL}/functions/v1/probe`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
},
body: JSON.stringify({ uuid: `order/${orderId}` }),
});
const { status, value } = await response.json();
```
### 5) Resolve external promises (HITL or webhook)
Intent: resume a blocked workflow.
```ts
await fetch(`${RESONATE_URL}/promises/${promiseId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: "RESOLVED", value: { data: "true" } }),
});
```
## Starting Workflows
There are two patterns for starting workflows in Supabase Edge Functions:
### Pattern A: Use the start/probe template (Recommended)
If your template provides `start/` and `probe/` functions, use them:
```ts
// From your frontend or another service
const response = await fetch(`${SUPABASE_URL}/functions/v1/start`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
},
body: JSON.stringify({
uuid: `countdown-${crypto.randomUUID()}`,
func: "durableCountdown",
args: [name, durationMinutes, targetTime],
}),
});
```
The `start/` function internally calls `resonate.run()` for you.
### Pattern B: Custom Deno.serve() with resonate.run()
If you need custom routing or the template doesn't fit your needs:
```ts
// supabase/functions/flows/index.ts
import { Resonate, type Context } from "@resonatehq/supabase";
const resonate = new Resonate({
url: Deno.env.get("RESONATE_URL")!,
token: Deno.env.get("RESONATE_TOKEN")
});
function* durableCountdown(ctx: Context, name: string, durationMinutes: number) {
let remaining = durationMinutes;
while (remaining > 0) {
yield* ctx.run(sendWebhook, { type: "tick", name, remaining });
yield* ctx.sleep(60 * 1000);
remaining--;
}
yield* ctx.run(sendWebhook, { type: "complete", name });
return { status: "completed", name };
}
resonate.register("durableCountdown", durableCountdown);
// Custom request handler
Deno.serve(async (req) => {
const url = new URL(req.url);
// Start a new workflow
if (req.method === "POST" && url.pathname.endsWith("/start")) {
const { name, durationMinutes } = await req.json();
const promiseId = `countdown-${crypto.randomUUID()}`;
// This starts AND executes the workflow
resonate.run(promiseId, durableCountdown, name, durationMinutes);
return new Response(JSON.stringify({ promiseId }), {
headers: { "Content-Type": "application/json" }
});
}
// List active workflows by querying Resonate server directly
if (req.method === "GET" && url.pathname.endsWith("/list")) {
const resonateUrl = Deno.env.get("RESONATE_URL")!;
const token = Deno.env.get("RESONATE_TOKEN");
const response = await fetch(
`${resonateUrl}/promises?id=countdown-*&state=pending&limit=50`,
{
headers: {
"Content-Type": "application/json",
...(token && { "Authorization": `Bearer ${token}` })
}
}
);
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" }
});
}
// Default: let Resonate shim handle workflow execution callbacks
return resonate.handler(req); // Direct call, NOT a factory
});
```
### Key Insight: resonate.run() vs HTTP API
**Do NOT create promises via direct HTTP API to start workflows.**
```ts
// ❌ WRONG - This creates a promise but doesn't execute workflow code
await fetch(`${RESONATE_URL}/promises`, {
method: "POST",
body: JSON.stringify({ id: "my-workflow", timeout: 86400000 })
});
// ✅ CORRECT - This creates the promise AND executes the workflow
resonate.run("my-workflow", myWorkflowFunction, arg1, arg2);
```
The HTTP API (`POST /promises`) is for creating standalone promises that will be resolved externally (like human-in-the-loop). To actually RUN workflow code, you must use `resonate.run()`, `resonate.rpc()`, or the `start/` template endpoint.
## Code patterns
### Durable sleep
```ts
function* reminder(ctx: Context, userId: string) {
yield* ctx.run(sendEmail, userId);
yield* ctx.sleep(24 * 60 * 60 * 1000);
yield* ctx.run(sendFollowUp, userId);
}
```
### Structured concurrency (fork-join)
```ts
function* validate(ctx: Context, orderId: string) {
const a = yield* ctx.beginRun(checkInventory, orderId);
عرض على GitHub