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/coreDefine 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 inngestimport { 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:
| Term | Meaning |
|---|---|
attempt | One-based failed execution attempt currently being classified or recorded. |
attempts | Maximum total attempts, including the first try. |
| retry | Run the same job again because the failed attempt is retryable. |
| backoff | Delay before the next retry. Fixed and exponential helpers compute this for Beignet-owned workers. |
| terminal failure | A non-retryable failure or an exhausted retry policy. |
| dead letter | Durable 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.