Kwisple
Browse the docs

The platform repository › How it works

Jobs and the worker

Background work on pg-boss through @kwisple/jobs - every job, the enqueue options, concurrency, schedules, the worker process, inline jobs in development and how to add a job.

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:

ExportWhat
jobSchemasOne 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.

JobPayloadWhat it does
social.renderworkspaceId, postId, targetId (null = every platform version), userIdRenders a post's image(s) with Satori and resvg.
studio.exportworkspaceId, designIdDeclared only: nothing enqueues it and start.ts registers no handler.
site.revalidatesiteId, tagsPurges 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.scheduledworkspaceId, siteId, entryId, tags (≤ 100), kind (online/offline)Purges an entry's caches at its "online vanaf" / "offline vanaf" moment (The content engine).
cache.purgeAlluserIdPlatformbeheer's "Alles legen": purges every live site, one by one.
domain.verifydomainIdDeclared only: nothing enqueues it and start.ts registers no handler.
site.deployworkspaceId, siteIdRebuilds a static custom-code site after a publish (debounced per site).
asset.processworkspaceId, assetIdMakes the WebP grid preview of an uploaded image.
form.notifyworkspaceId, submissionIdEmits the form.submitted webhook event, then mails the form notification.
email.formConfirmworkspaceId, submissionId, subject, textThe confirmation mail to the visitor who sent a form.
email.notificationnotificationIdMails an important notification if the recipient wants mail.
email.digestnoneThe weekly "Wacht op jou" overview to every recipient (scheduled).
email.monthlyReportnoneThe monthly report to every opted-in client (scheduled).
social.reviewRemindernoneOne reminder for approvals open longer than two days (scheduled).
social.connectionHealthnoneRenews expiring social tokens, warns about connections that need a new login (scheduled).
email.passwordResetuserId, tokenThe "Wachtwoord vergeten" mail.
email.changeConfirmuserId, newEmail, tokenConfirms a new sign-in address.
email.changeNoticeuserId, oldEmail, newEmailTells the old address that it changed.
email.invitationkind (workspace/agency), invitationIdThe invitation mail (ids only, no addresses in the payload).
social.publishworkspaceId, targetIdOne publishing attempt of a platform version on a connected account.
social.dispatchnoneSweep over scheduled posts that are due (scheduled every 5 minutes).
analytics.rollupnoneRaw statistics events → daily aggregates, plus retention (scheduled).
bug.notifyidMails a new "Probleem melden" report to every platform admin.
webhook.emitworkspaceId, event, data, eventId, occurredAtFans an outgoing webhook event out to one webhook.deliver per endpoint.
webhook.deliversame + endpointIdOne signed POST to one endpoint.
analytics.benchmarknonePlatform-wide benchmarks per sector and week, plus pruning of webhook deliveries, OAuth rows and revoked agent tokens (scheduled).
trash.purgenoneDeletes what has been in a trash for more than 30 days (scheduled).
billing.syncsubscriptionIdMirrors one Stripe subscription onto its agency (queued by the Stripe webhook).
billing.clientSyncaccountId, subscriptionIdA client's package subscription on an agency's own Stripe account.
billing.sweepnoneManual subscriptions whose period ended renew or stop (scheduled).
workspace.exportworkspaceId, userIdEverything of one client as a zip in storage; the requester gets a notification.
brand.guideworkspaceId, userId, localeThe brand guide as a PDF, in the requester's language.
website.reviewworkspaceId, reviewIdA Websitecheck: AI analysis of one site's last 30 days.
website.reviewMonthlynoneStarts a Websitecheck for every live site with statistics (scheduled).
signature.logoworkspaceIdA PNG copy of the logo for e-mail signatures.
storage.purgeprefix (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:

Optionpg-boss meaningDefault
retryLimitattempts after the first failure3 (with retryBackoff: true always on)
retryDelayfirst retry delay in seconds; doubles per attempt with jitterpg-boss default
retryDelayMaxupper bound for the doubling delay, in secondsnone
singletonKeyat most one queued job per keynone
singletonSecondsdebounce window for singletonKey: at most one job per key per windownone
singletonNextSlota duplicate within the window is queued for the next window instead of dropped, so the latest request always runsfalse
startAftera Date; the job is not picked up before itnow
deleteAfterSecondsremoves the finished job and its payload this long after it finishedpg-boss default

How the codebase uses them:

Call siteOptionsWhy
form.notify (apps/web/src/lib/form-notify.ts), email.invitation, bug.notifyretryLimit: 5mails must arrive
billing.sync (lib/services/billing.ts)singletonKey: subscriptionIdStripe sends bursts of events per subscription
cache.purgeAll (lib/actions/site-cache.ts)singletonKey: "cache.purgeAll", singletonSeconds: 30, retryLimit: 1a 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: 1many edits in a row render once more after the last one
site.deploy (lib/services/deploys.ts)singletonKey per site, singletonSeconds: 60, singletonNextSlot: trueone 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 tablethe service queues its own retries only for network refusals
asset.process backfill (lib/services/assets.ts)singletonKey: thumb:<id>, singletonSeconds: 3600never twice for the same asset
webhook.emit / webhook.deliver (lib/services/webhooks.ts)retryLimit: 10, retryDelay: 30, retryDelayMax: 3600, deleteAfterSeconds: 86400retry for hours, then forget the payload
brand.guide, signature.logosingletonSeconds (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/:

FileRole
index.tsProcess entry. Sets JOBS_INLINE=0 for itself (it is the worker), calls startWorkers(), stops pg-boss gracefully on SIGINT/SIGTERM.
start.tsstartWorkers(label): every work(...) registration and every schedule(...).
inline.tsinlineWorkersEnabled(), 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.ts

In 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:

JobCronWhen
social.dispatch*/5 * * * *every 5 minutes
analytics.rollup15 2 * * *daily 02:15
analytics.benchmark30 3 * * *daily 03:30, after the rollup
trash.purge45 3 * * *daily 03:45
billing.sweep15 4 * * *daily 04:15
social.connectionHealth20 7 * * *daily 07:20
email.digest0 8 * * 1Monday 08:00
social.reviewReminder30 9 * * *daily 09:30
email.monthlyReport0 9 1 * *the 1st of the month, 09:00
website.reviewMonthly0 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_INLINEResult
1on
0off: run the standalone worker
unseton 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

WhatKeptBy
Finished webhook jobs and payloads1 daydeleteAfterSeconds on webhook.emit / webhook.deliver
webhook_delivery log30 dayspruneWebhookDeliveries() inside analytics.benchmark
Revoked agent tokens, OAuth rowspruned nightlypruneRevokedAgentTokens(), pruneOAuth() inside analytics.benchmark
Raw statistics events30 daysanalytics.rollup
Visitor-hash saltsa dayanalytics.rollup
Consent proof3 yearsanalytics.rollup
Trashed posts, designs, media, websites, clients30 daystrash.purge (clients' files via storage.purge)

Other finished jobs follow pg-boss's own retention defaults.

Adding a job

  1. 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 takes z.object({}).default({}). Add a one-line comment above it.
  2. Write the handler. A short one inline in startWorkers, otherwise apps/dashboard/src/worker/handlers/<name>.ts exporting a JobHandler<"<name>">. Open tenant data with forWorkspace(db, payload.workspaceId); never trust that the row still exists (it may be trashed or deleted since).
  3. Register it in startWorkers (apps/dashboard/src/worker/start.ts) with await work("<name>", handler), add { concurrency } only if one slow job must not block others, and add it to the "listening for jobs" log line.
  4. Schedule it if recurring: await schedule("<name>", "<cron>", {}) next to the others, with a comment saying when and why.
  5. Enqueue it from the service layer, not from a component: await enqueue("<name>", {...}, { retryLimit, singletonKey, … }). If a person waits for the result in development, call ensureInlineWorkers() before it.
  6. Report back. Long work that a person asked for ends with a notification (as workspace.export and brand.guide do), not with a spinner that waits on the job.
  7. Test the service function the handler calls; apps/dashboard/test/jobs-assets.test.ts is an example of a job-related test (Testing and checks).
  8. Document it: the job table on this page, and docs/handleiding if people see its effect.

Last updated 2026-10-30