Background jobs
Overview
The kit runs as a single web process (no worker, no queue). Most maintenance
is deliberately cron-free — trial reminders are Stripe webhooks, credit refills
accrue lazily on read, bans and sessions expire on the next check. What remains
is occasional cleanup, and that's what the jobs scaffold in src/lib/jobs/
handles: an external scheduler hits an authenticated endpoint on a schedule
and the app runs the registered jobs.
This design needs no extra infrastructure, doesn't double-run across instances, and survives deploys (there's no in-process timer to lose).
Enabling it
Set a secret. Until you do, the endpoint returns 404 (feature off):
CRON_SECRET=<a long random string, 32+ chars>
Then point a scheduler at /api/cron/run with the secret as a Bearer token. A
VPS crontab, every 15 minutes:
*/15 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" \
https://your-app.com/api/cron/run
Or a GitHub Actions schedule:
on:
schedule:
- cron: "*/15 * * * *"
jobs:
cron:
runs-on: ubuntu-latest
steps:
- run: |
curl -fsS -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
https://your-app.com/api/cron/run
The endpoint
GET (or POST) /api/cron/run:
- No
CRON_SECRETset →404. - Missing/wrong Bearer token →
401(compared in constant time). - Valid → runs every job and returns
{ ok, ran: [{ name, ok, result, durationMs }] }.
A job that throws is isolated: it's logged and reported as ok: false, but the
other jobs still run. The response is 500 if any job failed, 200 otherwise.
Adding a job
Jobs live in src/lib/jobs/. Write a Job and register it — nothing else to
wire:
// src/lib/jobs/my-job.ts
import type { Job } from "./types";
export const myJob: Job = {
name: "my-job",
async run({ prisma, logger }) {
const { count } = await prisma.something.deleteMany({ where: { … } });
return { cleaned: count };
},
};
// src/lib/jobs/index.ts
export const jobs: Job[] = [purgeExpiredInvitations, myJob];
Jobs must be idempotent and safe to run concurrently — schedulers can overlap or retry. Keep each run bounded (batch/limit large deletes).
The shipped jobs
All are idempotent cleanup sweeps — safe to keep, remove, or copy:
| Job | What it does |
|---|---|
purge-expired-invitations | Removes pending invitations past their expiry |
purge-expired-sessions | Removes sessions past expiresAt (already invalid) |
purge-expired-verifications | Removes expired verification/reset tokens |
purge-old-login-events | Trims sign-in history older than 90 days |
purge-old-notifications | Removes read notifications older than 90 days (unread kept) |
release-stale-credit-reservations | Releases credit holds stuck in held > 24h, reclaiming the credits |
purge-soft-deleted-users | Hard-deletes users an admin soft-deleted > 30 days ago (their grace/restore window) |
Most are deleteMany calls; release-stale-credit-reservations reuses the
billing releaseReservation primitive to refund abandoned holds.