A job queueing abstraction built on Vercel Queues. Provides deduplication, status tracking, and retry logic out of the box.
Jobbable<TParams, TResult> is the abstract base class for all queue jobs. Extend it to define a job:
import { z } from "zod";
import { Jobbable, type JobConfig } from "@repo/vqs";
const syncPatientParamsSchema = z.object({
patientId: z.number(),
});
type SyncPatientParams = z.infer<typeof syncPatientParamsSchema>;
export class SyncPatientJobbable extends Jobbable<SyncPatientParams, void> {
readonly config: JobConfig = {
topic: "sync-patient", // Vercel Queue topic name (must be unique)
maxAttempts: 3, // Max delivery attempts before acknowledging
visibilityTimeoutSeconds: 300, // How long the message is hidden after delivery
ttlHours: 24, // How long the dedup key stays active
};
validate(params: unknown): params is SyncPatientParams {
return syncPatientParamsSchema.safeParse(params).success;
}
jobKey(params: SyncPatientParams): string | null {
return `sync-patient:${params.patientId}`;
}
async run(params: SyncPatientParams): Promise<void> {
// Your job logic here
}
}
| Method | Purpose |
|---|---|
config |
Job configuration (topic, retries, timeouts) |
validate(params) |
Type guard -- validates the incoming message payload |
jobKey(params) |
Deduplication key. Return null to skip dedup (every call enqueues) |
run(params) |
The actual job logic |
| Method | Default | Purpose |
|---|---|---|
entityId(params) |
undefined |
Associate the job with a database entity for tracking |
defaultKey(params) |
SHA-256 hash of params | Helper to generate a deterministic key from params |
Call performLater() to enqueue a job:
const job = new SyncPatientJobbable();
// Basic enqueue
await job.performLater({ patientId: 123 });
// With options
await job.performLater({ patientId: 123 }, {
delaySeconds: 60, // Delay delivery
idempotencyKey: "custom", // Override the idempotency key
});
performLater handles deduplication automatically -- if a job with the same jobKey already exists and hasn't expired (based on ttlHours), the enqueue is skipped.
Wires a Jobbable to a Next.js route handler for Vercel Queues:
// app/api/queue/sync-patient/route.ts
import { createQueueHandler } from "@repo/vqs";
import { SyncPatientJobbable } from "~/shared/jobs/sync-patient.jobbable";
export const POST = createQueueHandler(SyncPatientJobbable);
The handler takes care of:
running -> success / failed)apps/web/shared/jobs/<name>.jobbable.tsapps/web/app/api/queue/<topic>/route.ts:import { createQueueHandler } from "@repo/vqs";
import { MyJobbable } from "~/shared/jobs/my.jobbable";
export const POST = createQueueHandler(MyJobbable);
apps/web/vercel.json):{
"functions": {
"app/api/queue/<topic>/route.ts": {
"experimentalTriggers": [
{
"type": "queue/v2beta",
"topic": "<topic>",
"retryAfterSeconds": 300,
"initialDelaySeconds": 0,
"maxDeliveries": 3,
"maxConcurrency": 50
}
]
}
}
}
Important: Vercel Queues requires one topic per route file. Each Jobbable gets its own route.
VQS_DRIVER)performLater hands the message to a QueueDriver chosen once per process from VQS_DRIVER:
| Value | Behaviour |
|---|---|
vercel (default) |
Sends to Vercel Queues; the app/api/queue/<topic> route consumes it. |
local |
Runs the job in-process on the next tick, after the job_executions row is written. |
VQS_DRIVER=local is for local dev and E2E, where there is no Vercel Queue or OIDC token to call back into. It replaces vercel env pull for local job runs. Semantics differ from the real queue:
maxAttempts is ignored; a failing job is logged as Local job failed and not rethrown)delaySeconds is ignored (logged at info)job_executions and status tracking work exactly as in productionThe local driver is refused when VERCEL_ENV is production or preview: assertLocalDriverAllowed (exported from @repo/vqs/driver-env) throws LocalQueueDriverForbiddenError at boot from apps/web/instrumentation.ts and again on the first performLater.
When a job fails, retryWithMaxAttempts determines the backoff:
| Attempt | Backoff |
|---|---|
| 1 | 100s |
| 2 | 400s |
| 3 | 900s |
Formula: (10 * deliveryCount)^2 seconds. After maxAttempts is reached, the message is acknowledged (deleted).
All jobs are tracked in the job_executions table:
enqueued_id, key, payload, optional entityId, and ttlHours"running" when the handler picks up the messagerun() completes or throws