Writing an adapter
The obligations behind each optional method on QueueAdapter, what the framework guarantees you in return, and why declaring a capability you cannot actually honor is the one mistake that costs data.
Implement QueueAdapter, declare what your broker can do, and pass an instance
to runtimeConfig. The three built-ins go through exactly this contract, so
there is no private path yours is missing.
import type { QueueAdapter } from "questpie/queue";
class MyAdapter implements QueueAdapter {
capabilities = {
longRunningConsumer: true,
runOnceConsumer: false,
pushConsumer: false,
scheduling: false,
singleton: false,
executionTerminalState: false,
};
async start() {}
async stop() {}
async publish(jobName, payload, options, dispatchId) {
return "broker-job-id";
}
async schedule(jobName, cron, payload, options) {}
async unschedule(jobName) {}
on(event, handler) {}
async listen(handlers, options) {}
}What the framework hands you
dispatchId is stable logical identity
It arrives as the fourth publish argument and it is a UUID that survives
retries, broker retention and an adapter change. Use it as your broker's
physical dedupe id, and carry it back on the handler record so recovery can
match a delivery to the ledger row that created it.
Handlers are keyed by the durable job name
listen and runOnce receive a QueueHandlerMap, which is
Record<jobName, handler>. createPushConsumer gets one as args.handlers.
Each handler takes a QueueJobRecord of
{ id, data, dispatchId?, idempotencyKey?, secretPayload?, finalAttempt? }.
data is raw and you never validate it
Store and replay the payload exactly as given. QUESTPIE re-parses it against the job's Zod schema before the user handler runs, so a validation pass in your adapter is duplicated work at best.
The envelope namespace is reserved
A payload whose top-level __questpieQueue metadata matches the framework
protocol version and the physical job id is decoded as a QUESTPIE envelope.
Anything else is passed through untouched, so a user payload that happens to
carry a similar-looking key is not hijacked.
What you owe back
publishInTransaction
Implement it only when your adapter can genuinely insert through the application
Drizzle transaction it is handed. If your storage is separate, leave it off, or
set transactionalPublishing: false when the adapter is only sometimes
transactional. The runtime then routes through the durable dispatch ledger
described in
Transactional dispatch.
ensureQueue
queue.listen() calls it once per job before your consumers start, carrying the
job's declared queuePolicy. It exists so the worker and every publisher create
a queue with the same policy, since whoever creates it first wins and the policy
is fixed from then on. Omit it if your broker has no pre-declared queues.
executionTerminalState
This is the flag that gates encrypted payloads, and the only one where an optimistic answer loses data rather than throwing.
Set it true only when both hold. Your adapter preserves
options.secretPayload on every physical attempt, and your
inspectExecutionState() reads durable broker truth for every terminal path,
including timeouts, heartbeat expiry, stalls, cancellation and retry exhaustion
that happen outside the handler. It returns one of pending, active,
completed, failed or missing.
`finalAttempt` is not the capability
It is useful delivery metadata and nothing more. Reporting it correctly does
not qualify an adapter for secretPayload, because the broker can terminalize
work on paths where no delivery, and therefore no finalAttempt, ever
happened.
BullMQ is the worked example of getting this right by declining. It reports
finalAttempt accurately, and still sets executionTerminalState: false,
because stalled-job limits and custom backoff can end work outside the
processor. The framework then rejects a secret publish before it happens rather
than encrypting something it cannot later prove it erased.
Capability inference
Leave capabilities off entirely and the runtime derives it from your methods.
| Flag | Inferred from |
|---|---|
longRunningConsumer | listen is present |
runOnceConsumer | runOnce is present |
pushConsumer | createPushConsumer is present |
scheduling | schedule and unschedule are both functions |
singleton | false |
executionTerminalState | false |
Declaring a flag always wins over inference, with the one exception above.
executionTerminalState needs the declaration and a real
inspectExecutionState method, and having only one of the two resolves to
false.
Targeting Cloudflare Workers
The handler from questpie/adapters/cloudflare asserts compatibility before it
serves anything, and for the queue slot it looks for a runtime: "cloudflare"
property on the adapter and a createPushConsumer() method. An adapter meant
for Workers needs both, or that assertion throws with queue.adapter listed
among the issues.
Where each topic lives
| Topic | Page |
|---|---|
| The interface and the built-in adapters | Queue |
| The ledger, the relay and its bounds | Transactional dispatch |
| What a secret payload costs the caller | Secret payloads |
| Writing adapters for the other slots | Building a plugin |
Transactional dispatch
What happens when you publish a job inside a database transaction. pg-boss joins it, every other adapter commits an intent to a ledger table and a relay publishes after the commit, and both routes have to survive the process dying in between.
Indexing
When a write reaches the index, what happens without a queue, the methods for driving indexing yourself, and how to rebuild a collection whose rows predate the search config.