Skip to content

Cloudflare Workflows pipeline

Cloudflare Workflows gives you durable steps on Workers via step.do. Cron schedules on the workflow binding auto-create instances — no separate cron Worker. Failed steps retry with configurable backoff.

See Running the enrich pipeline durably for the model.

Your bundle runs inside the Worker (or calls out to a Node service that holds it — same bundle calls either way). For a Worker-hosted bundle, keep the database URL in secrets:

src/search.ts
import { samesake } from "@samesake/postgres";
import { llmRerank } from "@samesake/server";
import { products } from "./catalog.ts";
import { geminiEmbed, geminiGenerate } from "./gemini.ts";
export function createCatalogApp(env: Env) {
return samesake({
url: env.SAMESAKE_DATABASE_URL,
collection: products,
models: { embed: geminiEmbed, generate: geminiGenerate },
rerank: llmRerank(geminiGenerate),
});
}
src/workflows/catalog-sync.ts
import {
WorkflowEntrypoint,
type WorkflowEvent,
type WorkflowStep,
} from "cloudflare:workers";
import { createCatalogApp } from "../search.ts";
import { fetchChangedRows } from "../catalog-source.ts";
type Env = {
SAMESAKE_DATABASE_URL: string;
};
export class CatalogSync extends WorkflowEntrypoint<Env> {
async run(event: WorkflowEvent<unknown>, step: WorkflowStep) {
const app = createCatalogApp(this.env);
await step.do("upsert", async () => {
const rows = await fetchChangedRows();
await app.enrich.upsert(rows);
});
await step.do(
"enrich",
{
retries: { limit: 5, delay: "5 seconds", backoff: "exponential" },
},
() => app.enrich.enrich(),
);
}
}
export class CatalogMaintenance extends WorkflowEntrypoint<Env> {
async run(_event: WorkflowEvent<unknown>, step: WorkflowStep) {
const app = createCatalogApp(this.env);
// Image revalidation is a caller-owned @samesake/server workflow; retry failed rows here.
await step.do("retry-failed", () =>
app.enrich.retryFailed({ limit: 500 }),
);
}
}

The schedules array on each workflow binding runs the loop on a cron — Cloudflare creates a new instance automatically.

wrangler.toml
name = "samesake-pipeline"
main = "src/index.ts"
compatibility_date = "2026-06-09"
[[workflows]]
name = "catalog-sync"
binding = "CATALOG_SYNC"
class_name = "CatalogSync"
schedules = ["*/5 * * * *"]
[[workflows]]
name = "catalog-maintenance"
binding = "CATALOG_MAINTENANCE"
class_name = "CatalogMaintenance"
schedules = ["0 * * * *"]

Webhook fast-path — create an instance from a Worker

Section titled “Webhook fast-path — create an instance from a Worker”

When a product changes, create a workflow instance with that row in params, or push directly and let the scheduled sync pick it up:

src/index.ts
import { createCatalogApp } from "./search.ts";
import { CatalogSync } from "./workflows/catalog-sync.ts";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === "POST" && new URL(request.url).pathname === "/webhooks/product") {
const product = await request.json();
// Option A: push inline, scheduled sync handles enrich
const app = createCatalogApp(env);
await app.enrich.upsert([product]);
// Option B: trigger a sync workflow now
const instance = await env.CATALOG_SYNC.create({
params: { trigger: "webhook" },
});
return Response.json({ ok: true, instanceId: instance.id });
}
return new Response("Not found", { status: 404 });
},
};

Use step.sleep("name", "20 seconds") between steps if you need deliberate delays — instances in a waiting state don’t count toward concurrency limits.

Workers + Workflows keep everything at the edge if your app already lives there. samesake’s incrementality means each scheduled instance still runs two bundle calls over the whole collection — cheap when only dozens of rows changed. You don’t fan out per product; Cloudflare’s durability covers per-step retries.

The runnable, tested version of this guide lives in examples/pipeline-cloudflare-workflows — deterministic stubs, no API key. Run bun test (Postgres-backed examples need SAMESAKE_DATABASE_URL).