Circle V2 API Docs
    Preparing search index...

    Module @repo/vqs

    @repo/vqs -- Vercel Queue System

    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:

    • Validating the incoming message
    • Running the job with status tracking (running -> success / failed)
    • Retry logic with exponential backoff
    • Error logging
    1. Create the Jobbable in apps/web/shared/jobs/<name>.jobbable.ts
    2. Create the route at apps/web/app/api/queue/<topic>/route.ts:
      import { createQueueHandler } from "@repo/vqs";
      import { MyJobbable } from "~/shared/jobs/my.jobbable";

      export const POST = createQueueHandler(MyJobbable);
    3. Register in vercel.json (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.

    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:

    • single delivery, no retries (maxAttempts is ignored; a failing job is logged as Local job failed and not rethrown)
    • delaySeconds is ignored (logged at info)
    • dedup via job_executions and status tracking work exactly as in production
    • a job that enqueues another job runs the child through the same driver

    The 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:

    • Enqueue: Records enqueued_id, key, payload, optional entityId, and ttlHours
    • Running: Status set to "running" when the handler picks up the message
    • Success/Failed: Status updated after run() completes or throws

    Modules

    driver-env