# Jobs (/docs/code/jobs)

---
title: Jobs
description: 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.
kind: guide
package: questpie
---

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`.

```ts title="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.

```bash
questpie generate   # registers the job, adds queue.sendWelcomeEmail
```

## What 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

```ts title="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](/docs/infrastructure/queue/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.

```ts
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.

<Callout type="warn" title="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.
</Callout>

## 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`.      |

<Callout type="warn" title="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.
</Callout>

`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](/docs/code/jobs/dispatching) covers both sets in full.

## Recurring work

Add `options.cron`. A worker registers the schedule when it boots.

```ts title="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... */
	},
});
```

<Callout type="warn" title="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.
</Callout>

`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 |

```ts title="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](/docs/code/jobs/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](/docs/code/jobs/dispatching)                                  |
| Worker entrypoints, shutdown, capabilities        | [Workers](/docs/code/jobs/workers)                                          |
| Payloads the broker must not be able to read      | [Secret payloads](/docs/code/jobs/secret-payloads)                          |
| Which broker actually runs the job                | [Queue](/docs/infrastructure/queue)                                         |
| Publishing inside a transaction                   | [Transactional dispatch](/docs/infrastructure/queue/transactional-dispatch) |
| The hook that publishes on every write            | [Hooks](/docs/schema/hooks)                                                 |

## Next

**[Emails](/docs/code/emails)** is the typed template a welcome job renders, so
the copy lives beside the schema instead of inside the handler.
