Run a background job
Create a project now and process work after the request. This guide extends the Projects tutorial with a job that reads the saved project and logs its ID. You will record the job in SQLite alongside the project, deliver it through an outbox drain, and test retries and rollback.
The log gives you a visible result without another service. Replace the handler with your application work when you need to generate a report or call an integration. Jobs that charge money, send messages, or make other lasting changes also need idempotent handlers.
Before you start
Complete the Projects tutorial, including ownership and the UI, using the Next.js starter and SQLite. Keep its database, signed-in account, and app configuration. Run commands from that app's root with Bun. Other runtimes and job providers have their own setup in the Jobs reference.
This guide uses the existing outbox adapters and the generated inline job runner. The outbox saves pending work; the runner executes it when a drain picks it up. An inline dispatch by itself would run in the caller, so the create use case will enqueue into the transaction's outbox, rather than execute the handler.
You will edit these files after generating the job:
| File | Change |
|---|---|
features/projects/jobs/log-created.ts | Define the payload and work to perform |
features/projects/use-cases/create-project.ts | Save the project and job in one transaction |
infra/db/provider.ts | Share the existing transaction factory with the database test |
features/projects/tests/background-job.test.ts | Check queued delivery, retry, validation, and rollback |
Generate the job
bun beignet make job projects.log-created
bun install
bun run format
bun beignet db generate
bun beignet db migrate
bun beignet db statusThe generator adds lib/jobs.ts, the job and feature registry, the central
server/outbox.ts registry, an outbox table, and the jobs provider. It also wires
the transaction's outbox and creates app/api/cron/outbox/drain/route.ts.
Review and keep the new migration. If CRON_SECRET is absent from .env.local,
copy its generated entry from .env.example before starting the app.
Keep those generated registrations. Replace the generated job with this complete file:
// features/projects/jobs/log-created.ts
import { z } from "zod";
import { defineJob } from "@/lib/jobs";
export const LogCreatedJobPayloadSchema = z.object({ id: z.uuid() });
export type LogCreatedJobPayload = z.infer<typeof LogCreatedJobPayloadSchema>;
export const LogCreatedJob = defineJob("projects.log-created", {
payload: LogCreatedJobPayloadSchema,
async handle({ payload, ctx }) {
const project = await ctx.ports.projects.findById(payload.id);
if (!project) return;
ctx.ports.logger.info("Project creation processed", {
projectId: project.id,
});
},
});Pass a project ID rather than a request context or a user object. The handler loads the current record through the repository port. If the project has been deleted before delivery, there is no work left to do.
Record the job with the project
Apply this change to the create use case:
1234564 unmodified lines1112131213141415161718192021222324import "@beignet/core/server-only";import { enqueueJob } from "@beignet/core/outbox";import { LogCreatedJob } from "../jobs/log-created";import { requireUserId } from "@beignet/core/ports";import { useCase } from "../../../lib/use-case";import { CreateProjectInputSchema, ProjectSchema } from "../schemas";4 unmodified lines .output(ProjectSchema) .run(async ({ ctx, input }) => { const userId = requireUserId(ctx); const project = await ctx.ports.projects.create({ ...input, userId });
return project; return ctx.ports.uow.transaction(async (tx) => { const project = await tx.projects.create({ ...input, userId }); await enqueueJob( tx.outbox, LogCreatedJob, { id: project.id }, { maxAttempts: 3 }, ); return project; }); });requireUserId still runs before the write. Both tx.projects and tx.outbox
use the same database transaction. A failure rolls back the project and pending
job together. The outbox allows up to three delivery attempts for this job.
Keep this distinction when adapting the example: enqueueJob(tx.outbox, ...)
records work for later. ctx.ports.jobs.dispatch(...) uses the configured
runner immediately; the starter's inline runner would execute the handler in
the request.
Share the transaction setup with the test
Extract the existing transaction factory in infra/db/provider.ts. The app and
the isolated database test will then use the same repository and outbox wiring:
123452 unmodified lines8910101112131424 unmodified lines3940414142434445464748424344454 unmodified lines50515253545556575859606162636465import "@beignet/core/server-only";import type { UnitOfWorkPort } from "@beignet/core/ports";import { createProvider } from "@beignet/core/providers";import { createDrizzleSqliteIdempotencyPort,2 unmodified lines createDrizzleSqliteUnitOfWork, type DbPort,} from "@beignet/provider-db-drizzle/sqlite";import type { AppPorts } from "@/ports";import type { AppPorts, AppTransactionPorts } from "@/ports";import { ensureDatabaseReady } from "./database-ready";import { createRepositories } from "./repositories";import type * as schema from "./schema";24 unmodified lines idempotency, outbox, outboxAdmin, uow: createDrizzleSqliteUnitOfWork({ db: dbPort.drizzle, createTransactionPorts: (tx) => ({ ...createRepositories(tx), idempotency: createDrizzleSqliteIdempotencyPort(tx), outbox: createDrizzleSqliteOutboxPort(tx), }), }), uow: createAppUnitOfWork(dbPort.drizzle), };
return {4 unmodified lines }; },});
export function createAppUnitOfWork( db: DbPort<typeof schema>["drizzle"],): UnitOfWorkPort<AppTransactionPorts> { return createDrizzleSqliteUnitOfWork({ db, createTransactionPorts: (tx) => ({ ...createRepositories(tx), idempotency: createDrizzleSqliteIdempotencyPort(tx), outbox: createDrizzleSqliteOutboxPort(tx), }), });}Every port returned by createTransactionPorts receives the same tx handle.
Keep this factory in the database provider so future generators can extend it.
Test delivery and rollback
Add this complete test. It calls the real create use case against an isolated SQLite database, then drains the registered job. Its first delivery fails on purpose; advancing the test clock makes the retry eligible without waiting.
// features/projects/tests/background-job.test.ts
import { createUseCaseTester } from "@beignet/core/application";
import { createInlineJobDispatcher } from "@beignet/core/jobs";
import { drainOutbox, enqueueJob } from "@beignet/core/outbox";
import {
createTestContextFactory,
createTestPorts,
createTestUserActor,
} from "@beignet/core/testing";
import {
createDrizzleSqliteOutboxAdminPort,
createDrizzleSqliteOutboxPort,
} from "@beignet/provider-db-drizzle/sqlite";
import type { AppContext } from "@/app-context";
import { LogCreatedJob } from "@/features/projects/jobs/log-created";
import { createProjectUseCase } from "@/features/projects/use-cases";
import { createAppUnitOfWork } from "@/infra/db/provider";
import { user } from "@/infra/db/schema";
import { createTestDatabase } from "@/infra/db/test-database";
import { initialPorts } from "@/infra/port-wiring";
import { expect, test } from "@/lib/beignet-test";
import { outboxRegistry } from "@/server/outbox";
test("project jobs wait for a drain, retry failures, and roll back with the write", async () => {
const database = await createTestDatabase();
const received: string[] = [];
let failDelivery = true;
try {
const owner = {
id: "job-owner",
name: "Owner",
email: "job-owner@example.com",
};
await database.db.insert(user).values({
...owner,
createdAt: new Date(),
updatedAt: new Date(),
});
const uow = createAppUnitOfWork(database.db);
const outbox = createDrizzleSqliteOutboxPort(database.db);
const admin = createDrizzleSqliteOutboxAdminPort(database.db);
const fixture = createTestPorts<AppContext["ports"]>({
base: initialPorts,
overrides: {
projects: database.repositories.projects,
uow,
logger: {
info(message) {
if (failDelivery) throw new Error("Temporary delivery failure");
received.push(message);
},
},
},
});
const createContext = createTestContextFactory<
AppContext,
AppContext["ports"]
>({
ports: fixture.ports,
actor: createTestUserActor(owner.id),
auth: { user: owner, session: { id: "job-session" } },
});
const tester = createUseCaseTester<AppContext>(createContext);
const project = await tester.run(createProjectUseCase, {
name: "Background work",
});
expect(
await database.repositories.projects.findById(project.id),
).not.toBeNull();
expect(await admin.countMessages({ status: "pending" })).toBe(1);
expect(received).toEqual([]);
const jobs = createInlineJobDispatcher<AppContext>({ ctx: createContext });
let now = new Date();
const drain = () =>
drainOutbox({
outbox,
registry: outboxRegistry,
jobs,
retryDelayMs: 1000,
now: () => now,
});
expect((await drain()).retried).toBe(1);
failDelivery = false;
now = new Date(now.getTime() + 1000);
expect((await drain()).delivered).toBe(1);
expect(received).toEqual(["Project creation processed"]);
expect(await admin.countMessages({ status: "delivered" })).toBe(1);
expect((await drain()).claimed).toBe(0);
let rolledBackId = "";
await expect(
uow.transaction(async (tx) => {
const unsaved = await tx.projects.create({
name: "Roll back",
userId: owner.id,
});
rolledBackId = unsaved.id;
await enqueueJob(tx.outbox, LogCreatedJob, { id: unsaved.id });
throw new Error("Cancel transaction");
}),
).rejects.toThrow("Cancel transaction");
expect(
await database.repositories.projects.findById(rolledBackId),
).toBeNull();
expect(await admin.countMessages()).toBe(1);
await expect(
enqueueJob(outbox, LogCreatedJob, { id: "not-a-uuid" }),
).rejects.toThrow();
expect(await admin.countMessages()).toBe(1);
} finally {
await database.close();
}
});bun run format
bun run test features/projects/tests/background-job.test.ts
bun beignet checkExpect the test to pass: the job stays pending until drained, a failed attempt retries, successful delivery logs once in this test, a subsequent drain finds nothing, and a rollback saves neither the project nor its job. An invalid payload must also be rejected. Passing this test does not imply exactly-once delivery in production.
Try it in the app
Restart the development server after the generated environment and schema changes:
bun run devSign in at localhost:3000 and open Projects. Create a project named Background work. Expect it to appear in the list and remain there after a reload.
The generated Next.js provider requests a bounded drain after the response. Look in the server terminal for Project creation processed with the new project's ID. From a second terminal in the same app directory, inspect and drain:
bun beignet outbox list --status pending
bun beignet outbox drain --batch-size 100
bun beignet outbox list --status deliveredExpect a delivered projects.log-created message. If automatic delivery already
ran, the pending list can be empty and the CLI drain can report zero claimed
messages. If the CLI performs the delivery, the log appears in that terminal.
Creating a second project records another job; editing an existing project does
not trigger this create-only workflow.
| What you see | What to check |
|---|---|
| No outbox table | Apply the generated migration to the same database used by the app. |
| Pending messages but no log | Run the drain and inspect its result; a pending row means the work has not finished. |
| No pending messages | Check the delivered list: the after-response drain may have finished already. |
| A retry or dead-letter result | Read the failure with bun beignet outbox show <message-id> and fix the handler or dependency. Follow recovery steps before requeueing. |
Keep delivery running in production
An after-response trigger can be missed during a crash or deployment. Schedule the generated authenticated drain route as a recovery sweep, including for scheduled retries. See outbox drain deployment for host setup. Keep the application and drain pointed at the same database; a local SQLite file is not shared between separate hosts.
Delivery is at least once. A crash after the handler succeeds but before the outbox acknowledges it can cause another execution. Duplicate log lines are acceptable here. Use idempotency for effects that must not repeat, and recheck current access in handlers when background work acts on behalf of a user.
For long-running work or a separate queue, choose Inngest or BullMQ. With either provider, keep the outbox drain running to enqueue committed work, and run the provider's function endpoint or worker to execute it. An outbox delivery then confirms queue acceptance, not handler completion.
Work with your agent
Use this prompt from the completed Projects app:
Read https://beignetjs.com/markdown/agent-instructions.md and this app's local
instructions. Follow https://beignetjs.com/markdown/guides/background-jobs.md.
First verify that the Projects tutorial, ownership rules, and SQLite setup are
present. Preserve existing app customizations and follow installed package
guidance if its version differs from the website.
Generate projects.log-created, record the job in the same transaction as project
creation, and add the database test for delivery, retry, validation, and rollback.
Run the formatter and beignet check. Verify the new project over HTTP or in the
browser, and inspect its outbox delivery. Explain any failed or skipped checks,
the files changed, and how I can repeat the verification.Continue with Jobs for payloads, dispatchers, and worker options, or return to Guides to choose the next task.