QUESTPIE
Infrastructure

Queue

Where a background job waits and who hands it to a worker. Nothing is built in, pg-boss on the Postgres you already run is what the starter writes, and moving to Redis or Cloudflare Queues is one import your job code never sees.

View markdown

A job says what to run. The adapter decides everything else, which process picks it up, what queued work survives that process dying, and whether cron and dedup exist at all. It is the only piece that moves when you change backends, so job(), publish() and your handler never name a broker.

The default

There is not one. No queue adapter is built in, and an app with no queue block is still a valid app. The service hands back an empty client.

What most readers actually run is whatever create-questpie wrote for them, and that is pg-boss unless they asked otherwise.

src/questpie/server/questpie.config.ts
import { pgBossAdapter } from "questpie/adapters/pg-boss";
import { runtimeConfig } from "questpie/app";

import { env } from "@/lib/env";

export default runtimeConfig({
	app: { url: env.APP_URL },
	db: { url: env.DATABASE_URL },
	queue: {
		adapter: pgBossAdapter({ connectionString: env.DATABASE_URL }),
	},
});

--queue bullmq writes bullMQAdapter({ connection: { url: env.REDIS_URL } }) instead and adds the bullmq and redis packages. --queue none leaves the block out.

No block, no queue

Codegen still generates the typed methods from your jobs/ files, but with no queue block the service returns {}. app.queue.sendEmail is then undefined and dispatching throws on the missing property. Nothing falls back.

Available adapters

AdapterFactory and importNeedsPick it when
pg-bosspgBossAdapter() from questpie/adapters/pg-bossthe Postgres you already haveAlways, until you have a reason not to. This is the starter default.
BullMQbullMQAdapter() from questpie/adapters/bullmqRedis, plus the bullmq packageYou already run Redis and want a dedicated high-throughput queue.
Cloudflare QueuescloudflareQueuesAdapter() from questpie/adapters/cloudflare-queuesa Queues producer bindingYou deploy to Workers. It is the only one that runs there.
yoursanything implementing QueueAdapterwhatever your broker needsThe three above do not cover your infrastructure.

None are re-exported from questpie/queue. pg-boss and bullmq are optional peer dependencies, pulled in only by their own subpath. Cloudflare needs neither.

What each one can do

An adapter declares capability flags. The consumer flags gate methods that throw when false, and executionTerminalState makes a secretPayload publish throw. scheduling throws from .schedule() but skips options.cron quietly, and singleton only advertises.

Capabilitypg-bossBullMQCloudflare Queues
longRunningConsumer, queue.listen()yesyesno
runOnceConsumer, queue.runOnce()yesyesno
pushConsumer, queue.createPushConsumer()nonoyes
scheduling, options.cron and .schedule()yesyesno
singleton, singletonKey dedupyesyesno
executionTerminalState, secretPayloadyesnono

The resolved flags are on app.queue.capabilities. If one codebase targets more than one runtime, read them and branch instead of catching the throw.

Swapping

queue takes an adapter instance and nothing else, so moving to Redis is one import and one expression. The generated app.queue.<name> methods, your handlers and every dispatch site are untouched.

src/questpie/server/questpie.config.ts
import { bullMQAdapter } from "questpie/adapters/bullmq";
import { runtimeConfig } from "questpie/app";

import { env } from "@/lib/env";

export default runtimeConfig({
	app: { url: env.APP_URL },
	db: { url: env.DATABASE_URL },
	queue: {
		adapter: bullMQAdapter({ connection: { url: env.REDIS_URL } }),
	},
});

Two things move under you. Anything capability-gated in the table above stops working the moment you pick an adapter without that flag, which is what makes the jump to Cloudflare Queues the expensive one. And dispatch inside a transaction changes route, covered in Transactional dispatch.

pg-boss options

PgBossAdapterOptions is pg-boss's own ConstructorOptions plus one QUESTPIE switch. Everything else, connectionString, schema, max and the rest, is spread straight into new PgBoss(...) and stays pg-boss's to document.

OptionDefaultWhat it does
useApplicationTransactiontrueInsert the job through the app's current Drizzle transaction. Set false for a separate database.

The adapter creates each queue on demand and caches the name, so you never declare queues yourself. It is also the only adapter that can insert the job in your transaction, which is why it is the default.

BullMQ options

OptionRequiredWhat it does
connectionyesioredis connection options, handed to every Queue and Worker.
queuePrefixnoRedis key prefix for this app's queues and workers.
workerOptionsnoExtra BullMQ WorkerOptions, minus connection and prefix.

start() is a no-op and connections open lazily. listen() builds one Worker per job name and maps teamSize to its concurrency. schedule() adds a repeatable under questpie:<jobName>, and unschedule() removes it by name.

Cloudflare Queues options

Pass queue or enqueue. The constructor throws if you pass neither.

OptionWhat it does
queueA producer binding, or a function returning one. The function is resolved on every publish.
enqueueYour own producer function, in place of a binding.
decodeTurns a raw pushed body into an envelope. Defaults to requiring a jobName string.

The adapter carries a runtime: "cloudflare" marker, and the handler from questpie/adapters/cloudflare refuses to start unless the queue adapter has both that marker and createPushConsumer(). Delays cap at 24 hours, and a startAfter or retryDelay past that throws.

`retryDelay` is seconds on every adapter

You always pass it in seconds. BullMQ multiplies by 1000 for its millisecond backoff.delay, and Cloudflare passes it through as delaySeconds. Do not pre-multiply for either one.

The interface

Every adapter, built-in or yours, implements this. It is the only seam the queue client talks to.

interface QueueAdapter {
	capabilities?: Partial<QueueAdapterCapabilities>;

	// Required.
	start(): Promise<void>;
	stop(): Promise<void>;
	publish(jobName, payload, options?, dispatchId?): Promise<string | null>;
	schedule(jobName, cron, payload, options?): Promise<void>;
	unschedule(jobName: string): Promise<void>;
	on(event: "error", handler: (error: Error) => void): void;

	// Optional. What you implement decides what the adapter can do.
	publishInTransaction?(
		tx,
		jobName,
		payload,
		options,
		dispatchId,
	): Promise<string | null>;
	transactionalPublishing?: boolean;
	inspectExecutionState?(jobName, adapterJobId): Promise<QueueExecutionState>;
	ensureQueue?(jobName, opts?): Promise<void>;
	listen?(handlers, options?): Promise<void>;
	runOnce?(handlers, options?): Promise<QueueRunOnceResult>;
	createPushConsumer?(args): QueuePushConsumerHandler;
}

Those six required members are the whole obligation. Leave capabilities off and the runtime infers it: the three consumer flags from whether the matching method exists, scheduling from schedule and unschedule both being functions, and singleton from false. executionTerminalState is the one exception, true only when you set it true and implement inspectExecutionState.

questpie/queue carries the whole contract surface, QueueAdapter plus the Queue* record, batch and capability types. Each adapter's option type ships from its own entry point, as PgBossAdapterOptions from questpie/adapters/pg-boss does.

Where each topic lives

TopicPage
Publishing inside a transaction, crash recoveryTransactional dispatch
The obligations behind each optional methodWriting an adapter
job(), publish(), PublishOptions, handlersJobs
Starting a worker in each consumer modelRunning a worker
Encrypted payloads and safe receiptsSecret payloads
The runtimeConfig file and its other slotsConfiguration

On this page