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