Jobs

Jobs represent explicit work to do. Use a job when the code says "do this work" and one handler owns the work: send an email, process an import, sync a record, generate a report, or call a slow third-party API.

Beignet jobs are typed definitions. Dispatchers decide whether they run inline, in tests, or through a durable provider such as Inngest.

bun add @beignet/core

Define a job

Create the app-bound defineJob builder once in lib/jobs.ts with createJobs<AppContext>() (see app-bound builders), then define jobs in feature files:

import { retry } from "@beignet/core/jobs";
import { z } from "zod";
import { defineJob } from "@/lib/jobs";

export const SendWelcomeEmailJob = defineJob("mail.welcome", {
  payload: z.object({
    email: z.string().email(),
  }),
  retry: retry.exponential({
    attempts: 3,
  }),
  async handle({ payload, ctx }) {
    await ctx.ports.mailer.send({
      to: payload.email,
      subject: "Welcome",
      text: "Thanks for joining.",
    });
  },
});

The payload schema is validated before the job is dispatched and before durable worker execution calls handle(...).

Dispatch jobs

Use cases dispatch jobs through ctx.ports.jobs:

await ctx.ports.jobs.dispatch(SendWelcomeEmailJob, {
  email: user.email,
});

That port can be an inline dispatcher in local development and tests, or a durable provider in production.

Inline dispatcher

Use the inline dispatcher when the work should run immediately in the same process:

import { createInlineJobDispatcher } from "@beignet/core/jobs";

const jobs = createInlineJobDispatcher<AppContext>({
  ctx,
  onError(error, job) {
    ctx.ports.logger.error("Job failed", {
      error,
      jobName: job.name,
    });
  },
});

Durable dispatch with Inngest

Install the Inngest provider when production jobs should be queued outside the request process:

bun add @beignet/provider-inngest @beignet/core inngest
import { inngestProvider } from "@beignet/provider-inngest";

export const providers = [inngestProvider];

The provider installs ctx.ports.jobs and exposes ctx.ports.inngest.client as an escape hatch for Inngest-specific features.

Workers are defined separately from your Beignet HTTP server:

// app/api/inngest/route.ts
import { createInngestJobFunction } from "@beignet/provider-inngest";
import { serve } from "inngest/next";
import { SendWelcomeEmailJob } from "@/features/users/jobs";
import { createBackgroundContext } from "@/infra/background-context";
import { inngest } from "@/infra/inngest";

const sendWelcomeEmail = createInngestJobFunction({
  client: inngest,
  job: SendWelcomeEmailJob,
  ctx: () => createBackgroundContext(),
});

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [sendWelcomeEmail],
});

createBackgroundContext() is app-owned. Build it next to your infrastructure so worker jobs get the same ports, logger, auth assumptions, and devtools instrumentation shape as cron routes or other background workflows.

See Deployment for how Beignet separates provider adapters from serverless-safe worker entrypoints. Direct provider jobs run through provider-owned entrypoints such as Inngest functions; outbox-backed jobs run through beignet outbox drain.

When a job defines a retry policy, the Inngest helper maps the total attempt count to Inngest's function retry setting, and createInngestJobFunction(...) fails fast if a job policy includes custom backoff, jitter, or retryIf behavior that Inngest cannot honor.

Retry policy

Use the retry helpers to make durable failure behavior explicit:

import { retry } from "@beignet/core/jobs";

class TemporaryProviderError extends Error {}

retry.none();

retry.fixed({
  attempts: 3,
  delay: "30s",
});

retry.exponential({
  attempts: 5,
  initialDelay: "10s",
  maxDelay: "10m",
  jitter: true,
  retryIf: ({ error }) => error instanceof TemporaryProviderError,
});

attempts is the maximum total attempts, including the first attempt. Use retryIf for app-owned transient/permanent error classification.

Retry vocabulary

Beignet uses the same retry language for jobs, outbox-backed delivery, and scheduled work:

TermMeaning
attemptOne-based failed execution attempt currently being classified or recorded.
attemptsMaximum total attempts, including the first try.
retryRun the same job again because the failed attempt is retryable.
backoffDelay before the next retry. Fixed and exponential helpers compute this for Beignet-owned workers.
terminal failureA non-retryable failure or an exhausted retry policy.
dead letterDurable terminal delivery state used by outbox-backed jobs. Direct job providers may expose their own equivalent failure queue.

Jobs and transactions

Avoid dispatching durable side effects before the database work commits. When a workflow uses Unit of Work, record a domain event during the transaction and let a listener dispatch the job after commit — see side effects after commit for the rule.

Use Outbox when the job enqueue must commit with the database write. The outbox can sit behind a transaction-scoped tx.jobs dispatcher, then a worker drains the durable row into your production job provider. beignet make job registers new feature job registries in an existing server/outbox.ts, and beignet doctor warns when a feature job is missing from the outbox registry (--fix registers it).

Retry-safe jobs

Job providers may retry handlers after process failures, timeouts, or transient errors. Put idempotency inside the job handler when the handler owns work that must not happen twice:

import {
  createIdempotencyFingerprint,
  runIdempotently,
} from "@beignet/core/idempotency";

export const GenerateReportJob = defineJob("reports.generate", {
  payload: z.object({
    reportId: z.string(),
    requestedBy: z.string(),
  }),
  retry: retry.exponential({ attempts: 3 }),
  async handle({ payload, ctx }) {
    await runIdempotently(ctx.ports.idempotency, {
      namespace: "reports.generate",
      key: payload.reportId,
      scope: { actorId: payload.requestedBy },
      fingerprint: await createIdempotencyFingerprint(payload),
      ttlSec: 60 * 60 * 24,
      run: () => ctx.ports.reports.generate(payload.reportId),
    });
  },
});

Use Idempotency for the full command, webhook, and job pattern.

Testing

In use-case tests, pass a job dispatcher that records dispatches:

const dispatchedJobs: Array<{ name: string; payload: unknown }> = [];

const jobs = {
  dispatch: async (job, payload) => {
    dispatchedJobs.push({ name: job.name, payload });
  },
};

In job tests, call the job handler directly with an in-memory context:

await SendWelcomeEmailJob.handle({
  job: SendWelcomeEmailJob,
  payload: { email: "user@example.com" },
  ctx,
});

Where jobs fit

Workflow primitives gives the full decision guide for commands, events, jobs, schedules, notifications, idempotency keys, and outbox records, and the workflows overview shows the transition pattern that decides when jobs should be dispatched.