Background jobs
Define typed background jobs with declarative retry, concurrency, debounce, and cron — triggered directly, by domain events, or on a schedule.
Background jobs are the single asynchronous execution engine of Standards. Every unit of async work — a direct enqueue, a domain-event reaction, a cron tick — becomes a durable row delivered at-least-once with retries, backoff, and a dead-letter queue.
Defining a job
Declare a job next to the code that owns it with defineJob. The definition carries the payload type, validation, and flow control — call sites never hand-build keys or magic strings.
import { defineJob } from "@stndrds/runtime";
import { z } from "zod";
const reindexPayload = z.object({ objectId: z.string() });
export const reindexJob = defineJob({
type: "search.reindex",
schema: reindexPayload,
maxAttempts: 5,
concurrency: { key: (p) => p.objectId, limit: 1 },
async handle(payload) {
// payload is typed as { objectId: string }
},
});type— unique job name; registering the same type twice throws.schema— any Standard Schema validator (Zod, Valibot…). The payload is validated at enqueue time (fail fast) and again at delivery.maxAttempts— deliveries before the job dead-letters (default3).handle— the worker function. Inline it when it needs no services; otherwise leave it out and bind late (see below).
Enqueueing
Enqueue through the injected background jobs service. The payload type is inferred from the definition — a wrong shape is a compile error.
await jobs.enqueue(reindexJob, { objectId: "products" });
// Per-call overrides
await jobs.enqueue(reindexJob, { objectId: "products" }, {
runAt: new Date(Date.now() + 60_000),
priority: 5,
});enqueueRaw({ type, payload }) remains available for genuinely dynamic job
types, but skips payload typing and the definition's flow control.
Flow control
Flow control is declared on the definition, so every enqueue site gets it for free:
| Option | Effect |
|---|---|
concurrency: { key, limit } | At most limit jobs sharing the key run at once (default limit 1 — strict serialization). Excess claims are deferred, not failed. |
idempotency: (payload) => string | Enqueues sharing the key return the existing active job instead of inserting a duplicate. |
debounce: { key, periodMs } | An enqueue within the quiet period postpones the pending job instead of inserting — bursts collapse into one run. |
export const syncContactJob = defineJob({
type: "crm.sync-contact",
schema: syncPayload,
idempotency: (p) => `sync:${p.contactId}`,
debounce: { key: (p) => p.contactId, periodMs: 30_000 },
async handle(payload) { /* … */ },
});Event-triggered jobs
Subscribe a job to domain events with on. Each matching event fans out into one job per subscribed type, so every consumer retries independently — one failing consumer never blocks or replays the others.
export const enrichContactJob = defineJob({
on: "record.created",
type: "crm.enrich-contact",
async handle(event) {
// event is a typed DomainEvent<"record.created">
if (event.data.objectName !== "contacts") return;
},
});Delivery is exactly the same machinery as direct jobs: at-least-once, per-job retries (default maxAttempts: 5 for event jobs), dead-letter on exhaustion. Events from record updates and deletes are journaled in the same transaction as the write; record creations and non-record events (form submissions, schema changes) are journaled immediately after commit — best-effort, with a small loss window if the process dies mid-write.
Cron jobs
Give a definition a cron pattern and each tick enqueues one row — scheduled work gets retry, DLQ, and observability like everything else. Ticks are deduplicated per minute window.
export const dailyReportJob = defineJob({
type: "reports.daily",
cron: { pattern: "0 3 * * *", timezone: "Europe/Paris" },
async handle() { /* … */ },
});Schedules are armed automatically at boot, but consuming the ticks is owned
by your host's schedule worker (the same one that consumes agent schedules):
call enqueueCronTick(jobs, registry, data.jobType) inside the tenant
context the job should run under.
Arming is attempted for every cron job independently, so one schedule failing to arm never prevents the others from being registered. If any of them fail, the worker still starts — refusing to boot over a schedule-adapter blip would turn a partial outage into a total one — and logs an error naming every job type that did not arm. Those jobs will not fire until the next successful boot, so that line is worth alerting on.
Binding handlers late
When a handler needs injected services, export the definition without handle and bind it at the composition root with .withHandler(). The same definition object still powers typed enqueues everywhere.
// modules/reports/daily-report.job.ts — colocated, no services
export const dailyReportJob = defineJob<{ scope: string }>({
type: "reports.daily",
cron: "0 3 * * *",
});
// composition root
registry.register(
dailyReportJob.withHandler(async (payload) => reportService.generate(payload.scope))
);Retries and the dead-letter queue
A throwing handler is retried with exponential backoff until maxAttempts, then the job lands in the dead-letter queue with its last error preserved.
// Inspect and replay dead-lettered jobs (tenant-scoped)
const dead = await jobs.listDeadLetters(50);
await jobs.replayDead(dead[0].id); // attempts reset to 0, re-deliveredTerminal jobs (completed, cancelled) are purged automatically after 30 days; dead-lettered jobs are kept until replayed or removed explicitly.