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 a context-bound helper once, then define jobs from it:

import { createJobHandlers } from "@beignet/core/jobs";
import { z } from "zod";
import type { AppContext } from "@/app-context";

const jobs = createJobHandlers<AppContext>();

export const SendWelcomeEmailJob = jobs.defineJob("mail.welcome", {
  payload: z.object({
    email: z.string().email(),
  }),
  retry: {
    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,
    });
  },
});

Inline dispatch is useful for tests, local examples, and workflows that do not need durable delivery.

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.

When a job defines retry.attempts, the Inngest helper maps it to Inngest's function retry setting.

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 an after-commit listener dispatch jobs after commit:

const post = await ctx.ports.uow.transaction(async (tx) => {
  const published = await tx.posts.publish(input.slug);

  await events.record(tx.events, PostPublished, {
    postId: published.id,
    slug: published.slug,
  });

  return published;
});

Then a listener can enqueue the job:

export const enqueuePublishedEmail = events.defineListener(PostPublished, {
  name: "posts.enqueue-published-email",
  async handle({ payload, ctx }) {
    await ctx.ports.jobs.dispatch(SendPostPublishedEmailJob, payload);
  },
});

Use Events for listener and event bus details.

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,
});

Events vs jobs

Use an event when the code says "this happened" and many parts of the app may care. Use a job when the code says "do this work" and one handler owns the work. Use a schedule when the code says "run this at this time"; schedule handlers should usually dispatch jobs for durable work.