Skip to content
RELKIT
Esc
↑↓navigate↵open⌘Jpreview
On this page

Your first durable job

Read the commerce Orders export task, its public job, provider binding, and accepted run handle.

Define an Orders export task, expose it through a job, and follow the run to completion. It is an optional extension after the Orders API tutorial, not a file replacement in your protected relkit-orders app. You need Bun, repository dependencies, and Docker for a live provider run.

Pre-setup

Use a native task provider in local Docker. Inngest is the starting path here; Trigger.dev and Effect MQ also have Docker recipes. Choose one and review its capabilities.

Install Inngest and local tooling

From this guide’s application/package root, install only its selected requirements:

bun add @relkit/docker@0.7.12 @relkit/inngest@0.8.1 @relkit/local@0.7.12 inngest@4.20.0

Keep these owner-specific versions and the lockfile together. Runtime SDKs belong in dependencies; build/type tooling belongs in devDependencies. Installing an adapter does not supply its optional SDK peers or enable the feature’s configuration.

Configure the jobs profile

Merge jobs and defaults.jobs into your root configuration. Keep your existing ID, env import and other settings; the example imports the environment contract from src/platform/env.ts.

/** Declares task application defaults with inspection disabled until selected. */
import { defineApp } from "@relkit/app/config";
import env from "@app/platform/env.js";
import { docker } from "@relkit/docker";
import { inngest } from "@relkit/inngest";

export default defineApp({
  id: "relkit-inngest",
  env,
  server: { port: 3000, maxBodyBytes: 1_048_576 },
  telemetry: { redaction: { mode: "development-redacted", maxBytes: 65_536 } },
  jobs: { default: docker(inngest()) },
  defaults: { jobs: "default" },
});

Docker supplies the local provider’s connection values; there are no new defineEnv secrets for this recipe. Jobs with profile: "default" use this binding. localJob() is the older function-target provider and is not a substitute for the native task provider used here.

Check and start after registering the task and job

Check Docker before continuing. After adding the resource and its service registration, run from the app root:

docker version
bun run check
bun run typecheck
bun run dev

check validates the graph; it starts no containers. dev starts only Docker recipes used by that graph, waits for readiness, and binds their generated connection values. An unused profile alone does not start a container. Keep the server running and use bun run relkit local status in another terminal to inspect the services. Local Docker covers detached startup and stopping services while retaining their volumes.

Use bun run relkit jobs capabilities to inspect the selected provider, then follow Your first durable job to submit and observe a run.

1. Define a task

When adapting this to a separate lean app, first complete Inngest’s selected installation and binding. The repository example already declares its native SDK and local tooling; copying task/job files does not install them. Provider creation tuples remain unavailable.

In your own application, with an existing orders service and no export task yet, run:

bun run relkit add task export-orders --service orders

This creates src/services/orders/tasks/export-orders.task.ts and registers exportOrdersTask in the service’s task map. Replace its placeholder input, output, and handler with your operation. The task command reference lists execution and version options. Skip scaffolding when reading the commerce example, which already contains these files.

A task owns durable execution. This one accepts order IDs, emits progress, and returns CSV text. Its helper exportOrderCsv lives in the commerce example; if adapting the pattern to your app, replace that helper with your own business operation and keep its schemas explicit.

/** Produces demonstration CSV through the ordinary order helper and emits validated progress around the durable export. */
import { defineTask } from "@relkit/app/tasks";
import { z } from "@relkit/app/schema";
import { exportOrderCsv } from "../helpers/fake-business.js";

const exportOrders = defineTask({
  id: "orders.export-orders",
  version: "1",
  execution: "durable",
  input: z.object({ orderIds: z.array(z.string()) }),
  output: z.object({ csv: z.string(), exported: z.number().int().nonnegative() }),
  progress: z.object({
    completed: z.number().int().nonnegative(),
    total: z.number().int().positive(),
  }),
  handler: async ({ orderIds }, context) => {
    await context.progress.emit({ completed: 0, total: orderIds.length });
    const csv = exportOrderCsv(orderIds);
    await context.progress.emit({ completed: orderIds.length, total: orderIds.length });
    return { csv, exported: orderIds.length };
  },
});

export default exportOrders;

The task ID and version are stable compatibility identifiers. Change the version when the input or output contract is intentionally incompatible.

2. Expose a job

In your own app, scaffold the job after creating the task:

bun run relkit add job export-orders --service orders \
  --target orders.export-orders-task

--target is the task’s stable ID. This creates src/services/orders/jobs/export-orders.job.ts and registers the job on the service. Review its provider profile and client policy before running it. It does not create the HTTP trigger below. See the job command reference for all flags.

The job gives the task a public name and client policy. The HTTP route below is a separate entry point; its handler accepts a run and returns 202, not the CSV output.

/** Registers the export task as a public job exposing trigger, get, list and watch with progress/output fields. */
import { defineJob } from "@relkit/app/jobs";
import exportOrders from "../tasks/export-orders.task.js";

const exportOrdersJob = defineJob({
  name: "exportOrders",
  id: "orders.export-orders",
  task: exportOrders,
  client: {
    public: true,
    operations: ["trigger", "get", "list", "watch"],
    fields: ["status", "progress", "output"],
  },
});

export default exportOrdersJob;

Use this job from the frontend

After frontend setup and pulling the commerce contract, render this component under the provider. It selects this job’s public exportOrders name:

"use client";

/** Submits one commerce export and observes the accepted run without replaying an uncertain submission. */
import { useJobRun, useJobTrigger, useRelkitClient } from "@relkit/client/react";

/** Keeps the accepted receipt separate from the task's eventual output.
 * @returns One explicit submission button and its receipt, observation, or failure state. */
export function ExportOrders() {
  const { status } = useRelkitClient();
  const submit = useJobTrigger("exportOrders");
  const run = useJobRun("exportOrders", submit.data ? { runId: submit.data.runId } : {});

  return (
    <section>
      <button
        disabled={status !== "ready" || !submit.isIdle}
        onClick={() => submit.mutate({ input: { orderIds: ["order-1"] } })}
      >
        Export orders
      </button>
      {submit.isPending && <p role="status">Submitting…</p>}
      {submit.isError && (
        <p role="alert">{String(submit.error)}. Check the outcome before submitting again.</p>
      )}
      {submit.data && <p>Run ID: {submit.data.runId}</p>}
      <p role="status">
        Connection: {run.connection}; run: {run.run?.status ?? "not accepted"}
      </p>
    </section>
  );
}

The task determines the orderIds input. The job’s client.operations permits triggering and observation, while client.fields selects public run data. The returned runId is an admission receipt; completion and output arrive through the run observer. The example keeps uncertain submissions visible instead of automatically sending them again.

Reference: useJobTrigger, useJobRun. Continue with job clients and recovery.

Add a separate HTTP trigger

/**
 * Accepts an order export through the public orders service and returns its durable
 * run receipt. The service owns the job descriptor; this route owns HTTP transport.
 */
import { defineRoute } from "@relkit/app/routes";
import orders from "../../../services/orders/service.js";

export const POST = defineRoute({
  id: "orders.export",
  handler: async (request) => {
    const input = (await request.json()) as { readonly orderIds: string[] };
    const run = await orders.exportOrders.trigger(input);
    return Response.json(run, { status: 202 });
  },
});

The commerce Orders service exports both the task and job. Its relkit.config.ts binds jobs.default to docker(inngest()), so the compiler can check durable capabilities. When adapting this to your own app, add a supported job binding before starting a run.

The generated job owns admission and client operations while the task remains the unit of execution and compatibility.

For the browser side of this declaration, follow the React trigger and run example. It uses this job’s exportOrders name and the task’s generated input/output types.

3. Trigger and observe

The generated trigger receives { input, options } and returns an accepted RunHandle. For example, call client.jobs.exportOrders.trigger({ input: { orderIds }, options: { operationId } }), then read client.jobs.exportOrders.runs.get({ runId: accepted.runId }). Keep the accepted runId and operationId; a handle is an admission receipt, not the task output.

Use job clients for the complete trigger/watch lifecycle and progress and streams for live updates.

4. Run locally

bun run check
bun run typecheck
bun ../../packages/cli/dist/bin.js local up --detach
bun ../../packages/cli/dist/bin.js local status

Start bun run dev after the provider reports ready. In another terminal, submit a run:

bun ../../packages/cli/dist/bin.js jobs capabilities
curl -i -X POST http://127.0.0.1:4000/orders/export \
  -H 'content-type: application/json' \
  -d '{"orderIds":["order-1"]}'

Expect HTTP 202 and a RunHandle with a runId; read or watch that ID until completion. The local provider is selected by the project binding. Use relkit local reset --dry-run before clearing local state.

Run bun run test from examples/commerce before changing the task. A passing test checks application behavior, while the Docker run checks the selected provider.

Reference and source