QUESTPIE
Code

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.

View markdown

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.

src/questpie/server/jobs/send-welcome-email.ts
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.sendWelcomeEmail

What that file produced

SurfaceWhere it shows up
Typed dispatchapp.queue.sendWelcomeEmail, and ctx.queue.sendWelcomeEmail in any handler
Name aliasapp.queue["send-welcome-email"], when name is a string literal
A worker runYour 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

src/questpie/server/collections/user.ts
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 }) => {};
  • payload is the output of schema, already parsed.
  • dispatchId is stable across retries and duplicate delivery. Pass it to a downstream provider as that provider's idempotency key.
  • idempotencyKey is what the caller passed, when they passed one.
  • locale is the app's default locale. The dispatching request's locale does not travel with the job.
  • The rest is the same AppContext a route or hook gets. That is db, collections, globals, queue, email, search, realtime, kv, storage, services, logger, t and app. It includes queue, 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.

OptionTypeWhat it does
prioritynumberRun order. pg-boss runs higher first, BullMQ runs lower first.
retryLimitnumberHow many times to retry a failed run.
retryDelaynumberSeconds between retries.
retryBackoffbooleanUse exponential backoff.
expireInSecondsnumberHow long a run may stay active. pg-boss retries or fails it past this, and defaults to 900.
startAfternumber | string | DateDelay the first run. A number is seconds.
cronstringRegister the job as recurring.
queuePolicystringpg-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.

src/questpie/server/jobs/purge-expired-sessions.ts
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.

ModelCallAdapters
Long-running processapp.queue.listen()pg-boss, BullMQ
One batch, then exitapp.queue.runOnce()pg-boss, BullMQ
Pushed batchesapp.queue.createPushConsumer()Cloudflare Queues
src/worker.ts
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.

KeyWhat it does
indexRecordsIndexes changed rows for search, off the request path.
scheduledTransitionMoves a record or global to a workflow stage at a set time.
storageCleanupDeletes orphaned files. Runs every minute on cron.

Where each topic lives

TopicPage
publish, schedule, unschedule, every optionDispatching
Worker entrypoints, shutdown, capabilitiesWorkers
Payloads the broker must not be able to readSecret payloads
Which broker actually runs the jobQueue
Publishing inside a transactionTransactional dispatch
The hook that publishes on every writeHooks

Next

Emails is the typed template a welcome job renders, so the copy lives beside the schema instead of inside the handler.

On this page