Background jobs
Defining, enqueueing and scheduling jobs — defineJob, the typed registry, cron triggers, step.run idempotency, and where withSystem is sanctioned. Use when work should not happen in the request, when adding a cron, or when a job retries strangely.
Generated from
.claude/skills/background-jobs/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.
Work leaves the request path by being a job: declared with defineJob in
src/lib/jobs/jobs/, registered in src/lib/jobs/registry.ts, enqueued with
enqueue(id, input) — never inngest.send directly (lint enforces it).
Defining one
export const cleanupJob = defineJob({
id: 'cleanup-pending-uploads',
cron: '0 3 * * *', // optional; UTC. The event trigger stays either way,
input: z.object({}), // so a cron job can still be enqueued while debugging.
handler: async ({ input, step, log }) => {
const stale = await step.run('find-stale', () => …);
await step.run('delete-rows', () => …);
log.info('cleaned', { count: stale.length });
},
});- Input is validated when the job RUNS, not only at enqueue — the payload
crossed a process boundary as JSON. Cron invocations carry no payload, so a
cron job's schema must parse
{}. step.runis the idempotency mechanism. On a retry, steps that already succeeded replay from their recorded result. Slice a job so each side effect is its own step, or a failure in step three re-runs step one.- Design handlers idempotent anyway — deletion by age, upsert by key, "already done" as a success. Retries are the normal case, not the edge.
- Use the bound
log, not the global logger: it carries the job id and run id, the job-world request id.
withSystem is sanctioned here — with its rules
A handler starts with no tenant, so src/lib/jobs/jobs/** is on the
no-unscoped-db allowlist. That is a responsibility, not a convenience:
scope every statement explicitly (by organization, by age, by key), because
nothing else will. The audit-retention job is the model — it is deliberately
the ONLY place in the codebase that deletes from audit_events, doing under
the system role what the request path is structurally unable to do.
The shipped jobs are the patterns
send-email— fan-out through a queue so signup never waits on Resend.cleanup-pending-uploads— nightly cron; bucket object deleted best-effort BEFORE the row, so bytes are never orphaned.audit-retention— weekly cron; age-based deletion, idempotent by construction.
Local development: npx inngest-cli dev discovers the serve route and runs
crons on schedule; without it jobs enqueue and simply wait.
Caching and revalidation
How mutations refresh pages — the typed revalidate registry, why importing next/cache is a lint error, and where the generator registers new features. Use when a list is stale after a mutation, or when the next/cache restriction fires.
Testing
How to test in this project — the unit/integration/e2e split, the tenant-isolation test every feature needs, the signed-in Playwright fixture, and how to tell a real pass from a test that never checked anything. Use when adding tests or when a test passes and you are not sure it should have.