[ 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
| Flag | Default | What it does |
|---|---|---|
--cron <expression> | declare a scheduled job instead of one with input | |
--test, --no-test | make.tests | write the test beside it, or skip it |
--dry-run | print 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:
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:
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
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
Describe the job's input in
app/jobs/index.ts, replacings.object({})with the fields a caller sends, such ass.object({ postId: s.string() }). The handler'sctx.inputtakes that type.Do the work through what the dispatcher's middleware publishes:
ctx.db,ctx.modelsandctx.login the template.Enqueue it from a controller with
ctx.jobs.enqueue(jobs.reports.daily, input), oncejobEnqueuer(queue)from@sdxc/jobs/routeris in the router's chain, or from a job handler or a test withdispatcher.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.