sdxc
SearchCtrlK

[ The sdxc command ]

Make a job

Write a background job with sdxc make job, declared in app/jobs/index.ts and mapped on the dispatcher, or a scheduled one with --cron.

Last updated 2026-10-19

A job is work that runs off the request: sending an email, calling a slow API, sweeping expired rows on a schedule. sdxc make job writes the handler in app/jobs/, declares the job in app/jobs/index.ts and maps it on the dispatcher, so it runs the first time something enqueues it:

sdxc make job reports/daily
Wrote app/jobs/reports/daily.ts
Wrote app/jobs/reports/daily.test.ts
Edited app/jobs/index.ts
Edited app/initializers/dispatcher.ts

Flags

FlagDefaultWhat it does
--cron <expression>declare a scheduled job instead of one with input
--test, --no-testmake.testswrite the test beside it, or skip it
--dry-runprint the files and the diffs, and change nothing
sdxc make job digest --cron "0 8 * * *"

What it writes

The handler logs the job's name on the invocation's log and leaves the work to you:

app/jobs/reports/daily.ts
import { createJobHandler } from "@sdxc/jobs";

import jobs from "~/app/jobs";

export default createJobHandler(jobs.reports.daily, async (ctx) => {
	ctx.log.set({ job: { name: "daily" } });
	// TODO: do the work, through the services on `ctx`
});

A --cron job is written from job.cron.stub, the same handler for a job each run of the schedule enqueues.

What it edits

The job is declared in app/jobs/index.ts with an empty input schema, and mapped on the dispatcher the initializer in app/initializers/ creates with createJobDispatcher:

--- a/app/jobs/index.ts
+++ b/app/jobs/index.ts
-import { jobs } from "@sdxc/jobs";
+import { jobs, job } from "@sdxc/jobs";
+import * as s from "remix/data-schema";

-export default jobs({});
+export default jobs({
+	reports: {
+		daily: job({ input: s.object({}) }),
+	},
+});
--- a/app/initializers/dispatcher.ts
+++ b/app/initializers/dispatcher.ts
+import jobs from "~/app/jobs";
@@
+dispatcher.map(jobs.reports.daily, () => import("~/app/jobs/reports/daily"));

With --cron, the declaration carries the schedule instead of an input:

digest: job({ cron: "0 8 * * *" }),

Cloudflare fires a schedule the Worker declares, so add the same expression to the Worker's triggers in cloudflare.config.ts:

cloudflare.config.ts
triggers: [triggers.queue({ name: manifest.name }), triggers.scheduled({ schedule: "0 8 * * *" })],

Names and keys

The key is the file's last segment in camelCase, and each folder is a group: reports/send-summary is jobs.reports.sendSummary, in app/jobs/reports/send-summary.ts. A folder whose jobs are already mapped under a group of another spelling joins that group, so a new job in app/jobs/activitypub/ lands beside its siblings under activityPub. The key is the job's name on the queue, so rename it only when no message under the old name is waiting.

The test

app/jobs/reports/daily.test.ts
import { describe, expect, test } from "vitest";

import handler from "~/app/jobs/reports/daily";

describe("daily", () => {
	test("exports the handler the dispatcher maps", () => {
		expect(handler).toBeDefined();
	});

	test.todo("does the Daily work");
});
bun run test app/jobs/reports/daily.test.ts

Turn the test.todo into a run through the dispatcher once the job does its work.

Fill it in

  1. Describe the job's input in app/jobs/index.ts, replacing s.object({}) with the fields a caller sends, such as s.object({ postId: s.string() }). The handler's ctx.input takes that type.

  2. Do the work through what the dispatcher's middleware publishes: ctx.db, ctx.models and ctx.log in the template.

  3. Enqueue it from a controller with ctx.jobs.enqueue(jobs.reports.daily, input), once jobEnqueuer(queue) from @sdxc/jobs/router is in the router's chain, or from a job handler or a test with dispatcher.enqueue.

Background jobs and cron covers enqueuing, retries, timeouts and schedules, and @sdxc/jobs the whole API.

Where to go next

  • Make a service — the rule a job carries out, kept apart so a controller or a command can call it too.

  • Make an email — the message a job sends.

  • Generate files — the flags, configuration and stubs every kind shares.

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.