Jobs
A job is one background task you declare in a file. QUESTPIE reads the file, validates its payload with Zod, and hands you a typed publish method. A worker drains the queue later.
Some work should not hold up a response. Sending mail, calling a slow API, reindexing a thousand rows. A job is where that work goes.
Declare it
Put a file under your jobs/ directory. Default-export a job({ ... }) and
import job from questpie/services.
import { job } from "questpie/services";
import { z } from "zod";
export default job({
name: "send-welcome-email",
schema: z.object({ userId: z.string() }),
handler: async ({ payload, collections, email }) => {
const user = await collections.user.findOne({
where: { id: payload.userId },
});
if (!user) return;
await email.send({
to: user.email,
subject: "Welcome!",
html: `<p>Hi ${user.name}, thanks for signing up.</p>`,
});
},
options: { retryLimit: 3, retryDelay: 30, retryBackoff: true },
});Then build the dispatch surface. questpie add job send-welcome-email writes
the file and runs this step for you.
questpie generate # registers the job, adds queue.sendWelcomeEmailWhat that file produced
| Surface | Where it shows up |
|---|---|
| Typed dispatch | app.queue.sendWelcomeEmail, and ctx.queue.sendWelcomeEmail in any handler |
| Name alias | app.queue["send-welcome-email"], when name is a string literal |
| A worker run | Your handler, in a worker process, with the full app context |
The dispatch key comes from the file name, camel-cased.
jobs/send-welcome-email.ts is queue.sendWelcomeEmail whatever you passed to
name. That name is the durable string the broker stores. One file is one job.
Dispatch it
collection("user").hooks({
afterChange: async ({ data, operation, queue }) => {
if (operation !== "create") return;
await queue.sendWelcomeEmail.publish({ userId: data.id });
},
});publish() parses the payload against schema first. A wrong shape throws at
the call site, not in a worker an hour later. The worker parses it again before
your handler runs.
It resolves to a dispatchId, the stable id for this logical run, never to the
handler's return value. A job is fire and forget. If you need a result, write it
to the database from the handler and read it back.
Inside a hook, publish() joins the ambient transaction, so a rollback queues
nothing. See
Transactional dispatch.
The handler argument
The handler takes one argument. It is the validated payload plus the flat app
context, so destructure what you need.
handler: async ({ payload, dispatchId, idempotencyKey, locale, ...ctx }) => {};payloadis the output ofschema, already parsed.dispatchIdis stable across retries and duplicate delivery. Pass it to a downstream provider as that provider's idempotency key.idempotencyKeyis what the caller passed, when they passed one.localeis the app's default locale. The dispatching request's locale does not travel with the job.- The rest is the same
AppContexta route or hook gets. That isdb,collections,globals,queue,email,search,realtime,kv,storage,services,logger,tandapp. It includesqueue, so a job can publish other jobs.
A handler runs in system mode
Access rules are skipped and session is null. Every read and write in a
handler sees everything. Filter by hand when a job acts on behalf of one user.
Options
options holds the per-job defaults. Every field is optional.
| Option | Type | What it does |
|---|---|---|
priority | number | Run order. pg-boss runs higher first, BullMQ runs lower first. |
retryLimit | number | How many times to retry a failed run. |
retryDelay | number | Seconds between retries. |
retryBackoff | boolean | Use exponential backoff. |
expireInSeconds | number | How long a run may stay active. pg-boss retries or fails it past this, and defaults to 900. |
startAfter | number | string | Date | Delay the first run. A number is seconds. |
cron | string | Register the job as recurring. |
queuePolicy | string | pg-boss dedup policy. One of standard, short, singleton, stately, exclusive. |
These numbers are seconds
retryDelay, expireInSeconds and a numeric startAfter are all in seconds.
retryDelay: 30 is thirty seconds. BullMQ converts retryDelay and
startAfter to its own milliseconds, so never pre-multiply.
publish() takes these again as a second argument, cron aside, and a
call-time value wins. It also takes three the job cannot declare.
Dispatching covers both sets in full.
Recurring work
Add options.cron. A worker registers the schedule when it boots.
export default job({
name: "purge-expired-sessions",
schema: z.object({}),
options: { cron: "0 3 * * *" }, // every day at 03:00
handler: async ({ db }) => {
/* ...delete expired sessions... */
},
});A cron schema must accept an empty payload
Registration calls schema.parse({}), because a recurring run carries no
payload. A schema with a required field throws on boot. Use z.object({}), or
make every field optional.
startAfter is stripped from a cron job. It means nothing for a schedule. Use
queue.<name>.schedule(payload, cron) instead when the schedule carries a
payload, or when you decide it at runtime.
Running a worker
Publishing only enqueues. Something has to drain the queue. Which entrypoint you write depends on the adapter.
| Model | Call | Adapters |
|---|---|---|
| Long-running process | app.queue.listen() | pg-boss, BullMQ |
| One batch, then exit | app.queue.runOnce() | pg-boss, BullMQ |
| Pushed batches | app.queue.createPushConsumer() | Cloudflare Queues |
import { app } from "#questpie";
await app.queue.listen();Run that as its own process. Calling the wrong one for your adapter throws. Workers has all three, plus what each adapter reads.
Jobs you already have
The core module ships three, written the same way as yours. The framework publishes them, so you rarely call them.
| Key | What it does |
|---|---|
indexRecords | Indexes changed rows for search, off the request path. |
scheduledTransition | Moves a record or global to a workflow stage at a set time. |
storageCleanup | Deletes orphaned files. Runs every minute on cron. |
Where each topic lives
| Topic | Page |
|---|---|
publish, schedule, unschedule, every option | Dispatching |
| Worker entrypoints, shutdown, capabilities | Workers |
| Payloads the broker must not be able to read | Secret payloads |
| Which broker actually runs the job | Queue |
| Publishing inside a transaction | Transactional dispatch |
| The hook that publishes on every write | Hooks |
Next
Emails is the typed template a welcome job renders, so the copy lives beside the schema instead of inside the handler.