Anything slow, retryable or heavy runs as a job: PNG renders, exports, mails, webhooks,
rebuilds, nightly rollups. A request handler or server action only enqueues; a worker process
does the work with retries and backoff. The queue lives in Postgres (pg-boss), so local
development and a small production need no extra infrastructure, and enqueue() is the only
API callers use, so the backend can be swapped later without touching call sites.
The package: @kwisple/jobs
packages/jobs/src/index.ts exports everything:
| Export | What |
|---|---|
jobSchemas | One Zod schema per job name: the registry of every job that exists. |
JobName, JobPayload<N>, JobHandler<N> | Types inferred from jobSchemas. |
enqueue(name, payload, options?) | Validates the payload and sends the job. Returns the job id, or null when pg-boss dropped it (a singleton that is already queued). |
work(name, handler, { concurrency? }) | Registers a handler; payloads are validated again before it runs. |
schedule(name, cron, payload, { tz? }) | Registers or updates a recurring job (idempotent). |
jobState(name, id) | created, retry, active, completed, cancelled, failed or null. |
getBoss(), stopJobs() | The pg-boss instance (lazy, schema jobs, from DATABASE_URL) and a graceful stop. |
pg-boss keeps its tables in their own jobs schema in the same database; you never migrate it
yourself. Queues are created on first use (createQueue before every send, work and schedule).
Every job
From jobSchemas, with who sends it and what the handler does.
| Job | Payload | What it does |
|---|---|---|
social.render | workspaceId, postId, targetId (null = every platform version), userId | Renders a post's image(s) with Satori and resvg. |
studio.export | workspaceId, designId | Declared only: nothing enqueues it and start.ts registers no handler. |
site.revalidate | siteId, tags | Purges site:<id> plus the given tags in apps/web. A handler is registered, but no code enqueues it today: publishes purge inline through afterContentChange. |
cms.scheduled | workspaceId, siteId, entryId, tags (≤ 100), kind (online/offline) | Purges an entry's caches at its "online vanaf" / "offline vanaf" moment (The content engine). |
cache.purgeAll | userId | Platformbeheer's "Alles legen": purges every live site, one by one. |
domain.verify | domainId | Declared only: nothing enqueues it and start.ts registers no handler. |
site.deploy | workspaceId, siteId | Rebuilds a static custom-code site after a publish (debounced per site). |
asset.process | workspaceId, assetId | Makes the WebP grid preview of an uploaded image. |
form.notify | workspaceId, submissionId | Emits the form.submitted webhook event, then mails the form notification. |
email.formConfirm | workspaceId, submissionId, subject, text | The confirmation mail to the visitor who sent a form. |
email.notification | notificationId | Mails an important notification if the recipient wants mail. |
email.digest | none | The weekly "Wacht op jou" overview to every recipient (scheduled). |
email.monthlyReport | none | The monthly report to every opted-in client (scheduled). |
social.reviewReminder | none | One reminder for approvals open longer than two days (scheduled). |
social.connectionHealth | none | Renews expiring social tokens, warns about connections that need a new login (scheduled). |
email.passwordReset | userId, token | The "Wachtwoord vergeten" mail. |
email.changeConfirm | userId, newEmail, token | Confirms a new sign-in address. |
email.changeNotice | userId, oldEmail, newEmail | Tells the old address that it changed. |
email.invitation | kind (workspace/agency), invitationId | The invitation mail (ids only, no addresses in the payload). |
social.publish | workspaceId, targetId | One publishing attempt of a platform version on a connected account. |
social.dispatch | none | Sweep over scheduled posts that are due (scheduled every 5 minutes). |
analytics.rollup | none | Raw statistics events → daily aggregates, plus retention (scheduled). |
bug.notify | id | Mails a new "Probleem melden" report to every platform admin. |
webhook.emit | workspaceId, event, data, eventId, occurredAt | Fans an outgoing webhook event out to one webhook.deliver per endpoint. |
webhook.deliver | same + endpointId | One signed POST to one endpoint. |
analytics.benchmark | none | Platform-wide benchmarks per sector and week, plus pruning of webhook deliveries, OAuth rows and revoked agent tokens (scheduled). |
trash.purge | none | Deletes what has been in a trash for more than 30 days (scheduled). |
billing.sync | subscriptionId | Mirrors one Stripe subscription onto its agency (queued by the Stripe webhook). |
billing.clientSync | accountId, subscriptionId | A client's package subscription on an agency's own Stripe account. |
billing.sweep | none | Manual subscriptions whose period ended renew or stop (scheduled). |
workspace.export | workspaceId, userId | Everything of one client as a zip in storage; the requester gets a notification. |
brand.guide | workspaceId, userId, locale | The brand guide as a PDF, in the requester's language. |
website.review | workspaceId, reviewId | A Websitecheck: AI analysis of one site's last 30 days. |
website.reviewMonthly | none | Starts a Websitecheck for every live site with statistics (scheduled). |
signature.logo | workspaceId | A PNG copy of the logo for e-mail signatures. |
storage.purge | prefix (must match workspaces/<id>/) | Deletes a deleted client's files from storage. |
Enqueueing
import { enqueue } from "@kwisple/jobs";
await enqueue("workspace.export", { workspaceId: ctx.workspace.id, userId: ctx.user.id });The payload is parsed with the job's schema first, so a wrong payload throws in the caller, not in the worker. Options:
| Option | pg-boss meaning | Default |
|---|---|---|
retryLimit | attempts after the first failure | 3 (with retryBackoff: true always on) |
retryDelay | first retry delay in seconds; doubles per attempt with jitter | pg-boss default |
retryDelayMax | upper bound for the doubling delay, in seconds | none |
singletonKey | at most one queued job per key | none |
singletonSeconds | debounce window for singletonKey: at most one job per key per window | none |
singletonNextSlot | a duplicate within the window is queued for the next window instead of dropped, so the latest request always runs | false |
startAfter | a Date; the job is not picked up before it | now |
deleteAfterSeconds | removes the finished job and its payload this long after it finished | pg-boss default |
How the codebase uses them:
| Call site | Options | Why |
|---|---|---|
form.notify (apps/web/src/lib/form-notify.ts), email.invitation, bug.notify | retryLimit: 5 | mails must arrive |
billing.sync (lib/services/billing.ts) | singletonKey: subscriptionId | Stripe sends bursts of events per subscription |
cache.purgeAll (lib/actions/site-cache.ts) | singletonKey: "cache.purgeAll", singletonSeconds: 30, retryLimit: 1 | a second click while one runs returns null, shown as "already running" |
social.render (lib/actions/social.ts) | singletonKey: <post>:<target>, singletonSeconds: 5, singletonNextSlot: true, retryLimit: 1 | many edits in a row render once more after the last one |
site.deploy (lib/services/deploys.ts) | singletonKey per site, singletonSeconds: 60, singletonNextSlot: true | one rebuild per site per minute, and the last publish always gets one |
cms.scheduled (lib/services/site-cache.ts) | startAfter: moment + 5 s, singletonKey: cms.scheduled:<entry>:<kind>:<iso> | fire at the moment, once per moment |
social.publish retries (lib/services/social-publish.ts) | startAfter from a retry table | the service queues its own retries only for network refusals |
asset.process backfill (lib/services/assets.ts) | singletonKey: thumb:<id>, singletonSeconds: 3600 | never twice for the same asset |
webhook.emit / webhook.deliver (lib/services/webhooks.ts) | retryLimit: 10, retryDelay: 30, retryDelayMax: 3600, deleteAfterSeconds: 86400 | retry for hours, then forget the payload |
brand.guide, signature.logo | singletonSeconds (120 / 5) | debounce repeated clicks |
Treat enqueue as fallible where the user's action must still succeed: call sites that only
start background work end with .catch(...) and log, so a queue hiccup never fails a save.
Working: work() and concurrency
await work("webhook.deliver", async (job) => { await deliverWebhook(job); }, { concurrency: 5 });work fetches one job at a time (batchSize: 1). concurrency (default 1) becomes pg-boss
localConcurrency: that many jobs of the queue run side by side in this process. Only
webhook.deliver uses it today (5), so one slow receiver with a 10 s timeout does not hold up
everyone else's deliveries.
A handler that throws marks the attempt failed; pg-boss retries it according to the options the job was sent with. Handlers must be idempotent: a retry, a duplicate delivery or a manual re-run must not double anything. The nightly jobs are written that way (a rollup deletes and rebuilds its days; the purge only removes what is past retention).
Schedules: schedule()
await schedule("analytics.rollup", "15 2 * * *", {});A cron expression evaluated in tz (default Europe/Brussels). It is idempotent: the worker
calls it on every boot and pg-boss keeps one schedule per name. pg-boss turns each tick into an
ordinary job of that name, so the handler is registered with work like any other job.
The worker process
apps/dashboard/src/worker/:
| File | Role |
|---|---|
index.ts | Process entry. Sets JOBS_INLINE=0 for itself (it is the worker), calls startWorkers(), stops pg-boss gracefully on SIGINT/SIGTERM. |
start.ts | startWorkers(label): every work(...) registration and every schedule(...). |
inline.ts | inlineWorkersEnabled(), ensureInlineWorkers(), ensureInlineJob() and markInlineJob(). |
handlers/* | Handlers that are more than a line: asset-process, cache-purge-all, cms-scheduled, site-deploy, site-revalidate, social-render, storage-purge. |
Handlers reuse the same service functions as the server actions (lib/services/*, lib/email/*),
open tenant data with forWorkspace(db, workspaceId) from the payload, and log one line per
job with the label ([worker] or [jobs:inline]).
Run it:
pnpm --filter @kwisple/dashboard worker # tsx --env-file-if-exists=../../.env src/worker/index.tsIn production the worker target of the Dockerfile runs the same entry with tsx and
JOBS_INLINE=0 (Deploying the platform).
The schedule table
Registered at the end of startWorkers, all in Europe/Brussels time:
| Job | Cron | When |
|---|---|---|
social.dispatch | */5 * * * * | every 5 minutes |
analytics.rollup | 15 2 * * * | daily 02:15 |
analytics.benchmark | 30 3 * * * | daily 03:30, after the rollup |
trash.purge | 45 3 * * * | daily 03:45 |
billing.sweep | 15 4 * * * | daily 04:15 |
social.connectionHealth | 20 7 * * * | daily 07:20 |
email.digest | 0 8 * * 1 | Monday 08:00 |
social.reviewReminder | 30 9 * * * | daily 09:30 |
email.monthlyReport | 0 9 1 * * | the 1st of the month, 09:00 |
website.reviewMonthly | 0 6 2 * * | the 2nd of the month, 06:00 |
JOBS_INLINE: jobs inside the dashboard
inlineWorkersEnabled() decides whether the dashboard server registers the handlers in its own
process:
JOBS_INLINE | Result |
|---|---|
1 | on |
0 | off: run the standalone worker |
| unset | on outside production, off when NODE_ENV=production |
src/instrumentation.ts calls ensureInlineWorkers() at server start (after checking the
secrets), so in development pnpm dev processes its own jobs and you never start a worker.
Call sites that enqueue work a person waits for (asset.process, social.render,
cache.purgeAll, cms.scheduled) call ensureInlineWorkers() first, so a dev server that was
already running before a handler existed, or after hot reload, still picks it up.
ensureInlineJob("site.deploy") does the same for one job that was added later; start.ts
marks it with markInlineJob. Both are no-ops when inline workers are off.
In production compose sets JOBS_INLINE: "0" on the dashboard and runs the worker
container instead, sized for renders and exports.
Retention and housekeeping
| What | Kept | By |
|---|---|---|
| Finished webhook jobs and payloads | 1 day | deleteAfterSeconds on webhook.emit / webhook.deliver |
webhook_delivery log | 30 days | pruneWebhookDeliveries() inside analytics.benchmark |
| Revoked agent tokens, OAuth rows | pruned nightly | pruneRevokedAgentTokens(), pruneOAuth() inside analytics.benchmark |
| Raw statistics events | 30 days | analytics.rollup |
| Visitor-hash salts | a day | analytics.rollup |
| Consent proof | 3 years | analytics.rollup |
| Trashed posts, designs, media, websites, clients | 30 days | trash.purge (clients' files via storage.purge) |
Other finished jobs follow pg-boss's own retention defaults.
Adding a job
- Declare it in
jobSchemas(packages/jobs/src/index.ts): a dotted name (<module>.<verb>) and a Zod object. Put ids in the payload, not data: the handler loads what it needs, so a retry works on fresh rows and no personal data sits in the queue. Cap strings with.max(). A scheduled job takesz.object({}).default({}). Add a one-line comment above it. - Write the handler. A short one inline in
startWorkers, otherwiseapps/dashboard/src/worker/handlers/<name>.tsexporting aJobHandler<"<name>">. Open tenant data withforWorkspace(db, payload.workspaceId); never trust that the row still exists (it may be trashed or deleted since). - Register it in
startWorkers(apps/dashboard/src/worker/start.ts) withawait work("<name>", handler), add{ concurrency }only if one slow job must not block others, and add it to the "listening for jobs" log line. - Schedule it if recurring:
await schedule("<name>", "<cron>", {})next to the others, with a comment saying when and why. - Enqueue it from the service layer, not from a component:
await enqueue("<name>", {...}, { retryLimit, singletonKey, … }). If a person waits for the result in development, callensureInlineWorkers()before it. - Report back. Long work that a person asked for ends with a notification (as
workspace.exportandbrand.guidedo), not with a spinner that waits on the job. - Test the service function the handler calls;
apps/dashboard/test/jobs-assets.test.tsis an example of a job-related test (Testing and checks). - Document it: the job table on this page, and
docs/handleidingif people see its effect.