Packages

Jobs

A Trigger.dev job port with test drivers, billing and credit side-effects are enqueued, not inline.

@caisson/jobs is the background-work port. Trigger.dev (self-hostable) is the default driver; a test driver runs jobs deterministically under the standards gate, so suites assert on job behavior without a queue.

The contract

Billing and credit side-effects are enqueued, never run inline in a request handler. A webhook returns fast, and the money-moving work runs as a job, so a dropped connection mid-request never leaves a half-applied charge or a half-granted credit.

import { createInMemoryQueue, defineTask } from "@caisson/jobs";

const queue = createInMemoryQueue([
  defineTask("grant-credits", grantCreditsPayload, async (payload) => {
    /* credit ledger write */
  }),
]);

// The webhook handler returns immediately; the credit grant runs as a job.
await queue.enqueue("grant-credits", { accountId, sourceEventId });

The same queue.enqueue call runs against the in-memory driver (the test driver) under bun run check, so the enqueue contract is verified without standing up Trigger.dev.

API reference

The job port

Every driver (in-memory, Trigger.dev, pg-boss, BullMQ) implements the same one-method contract. The credit and billing side-effects depend only on this port, so swapping the driver never touches a call site.

interface JobQueue {
  enqueue(
    name: string,
    payload: unknown,
    options?: EnqueueOptions,
  ): Promise<void>;
}

interface EnqueueOptions {
  idempotencyKey?: string;
  singletonKey?: string;
}

idempotencyKey dedupes a retry of the same logical job, each driver has its own mechanism (an in-memory Set, a derived pg-boss/BullMQ job id, Trigger.dev's native option). singletonKey suppresses overlap instead: at most one job with that key queued or active at once, the guard for a recurring job whose previous run hasn't finished.

function defineTask<T>(
  name: string,
  schema: ZodType<T>,
  handler: (payload: T) => Promise<void>,
): TaskDefinition<unknown>;

Defines a typed task. T is inferred from schema, so a handler/schema mismatch is a compile error. Every driver validates an incoming payload against this same .strict() schema before the handler runs, a bad payload throws ValidationError, never a silent pass-through.

interface TaskDefinition<T> {
  name: string;
  schema: ZodType<T>;
  handler: (payload: T) => Promise<void>;
}

defineTask's return type, the registry entry every driver (createInMemoryQueue, createTriggerJobQueue, and the rest) takes an array of.

interface JobConsumer {
  work(name: string): Promise<WorkHandle>;
}

interface WorkHandle {
  stop(): Promise<void>;
}

interface JobLedger {
  getQueueState(name: string): Promise<QueueState>;
}

interface QueueState {
  queuedCount: number;
  activeCount: number;
  failedCount: number;
}

work(name) is the claim side of the port, resolved against the same task registry enqueue uses, it throws NotFoundError for an unregistered name. JobLedger is an optional visibility read: a driver only implements it if it can answer truthfully. Not every driver consumes for real or reports state, see below.

The test driver

function createInMemoryQueue(
  tasks: readonly TaskDefinition<unknown>[],
): JobQueue & JobConsumer & JobLedger;

The driver bun run check runs against. enqueue looks up the task by name (NotFoundError if unregistered), validates the payload, then awaits the handler synchronously: no queue, no network, no timing race, so a test asserts "this event enqueued that task with that payload" in a plain await. idempotencyKey dedupe is a Set keyed on name\0key; singletonKey overlap suppression is a second Set tracking in-flight keys, released only once the handler settles. A handler that throws increments a per-name failure counter read back through getQueueState: queuedCount and activeCount are always 0 (an honest zero, not a faked pending count: there's no backlog to poll in-memory). work(name) resolves a WorkHandle for port symmetry only; its stop() is a no-op.

The Trigger.dev driver

function createTriggerJobQueue(
  tasks: readonly TaskDefinition<unknown>[],
  config: TriggerJobQueueConfig,
): JobQueue & JobConsumer;

interface TriggerJobQueueConfig {
  secretKey?: string; // TRIGGER_SECRET_KEY
  apiUrl?: string; // TRIGGER_API_URL
  client?: TriggerClient; // injected in tests instead of secretKey
}

interface TriggerClient {
  trigger(
    taskId: string,
    payload: unknown,
    options?: { idempotencyKey?: string },
  ): Promise<unknown>;
}

The production driver. Registering tasks also registers each one as a real Trigger.dev task(): name becomes the task id and handler becomes its run, validated against the identical schema the in-memory driver uses. Trigger.dev's hosted platform is the actual consumer; there's no local "start consuming" call in the SDK, so work() exists only for port symmetry and its stop() is a no-op.

enqueue validates locally first, a bad payload throws ValidationError before anything leaves the process, then calls tasks.trigger(). idempotencyKey passes straight through as Trigger.dev's native option. singletonKey is not mapped: Trigger.dev's hosted scheduler owns overlap for scheduled tasks, and faking client-side suppression would be a lie the driver refuses to tell. Construction is fail-closed, calling createTriggerJobQueue without a secretKey and without an injected client throws ConfigError immediately, not on the first enqueue. This driver implements no JobLedger: Trigger.dev exposes no local queue-state read.

Producer-side dedup

function withAdvisoryXactLock<T>(
  tx: TenantExecutor,
  key: string,
  fn: () => Promise<T>,
): Promise<T>;

A Postgres transaction-scoped advisory lock for a check-then-enqueue critical section: "enqueue a digest only if none is pending and the last one fired over an hour ago" is a read-then-write race two workers can both pass. withAdvisoryXactLock issues pg_advisory_xact_lock before running fn, keyed by a stable id derived internally from sha256(key); the lock auto-releases at transaction end, so a throw inside fn never leaks it.

Other drivers

createPgBossJobQueue (Postgres, SKIP LOCKED) and createBullMqJobQueue (Redis) implement the same port with real local dequeue and a truthful JobLedger, for teams consuming without Trigger.dev's hosted platform. Both are fail-closed at construction the same way as the Trigger.dev driver: no connection and no injected client throws ConfigError.