Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Every change to this repo needs a review from a code owner (see CONTRIBUTING in mgpl-diagnostics/.github).
* @BackStacked
21 changes: 21 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
minor-and-patch:
update-types: [minor, patch]
ignore:
# our own packages are released together and bumped by hand
- dependency-name: "@mgpl-diagnostics/*"
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
# The job id "ci" is the required status check on main (see mgpl-diagnostics/.github scripts/protect-repos.mjs).
ci:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
options: >-
--health-cmd "redis-cli ping" --health-interval 2s --health-timeout 2s --health-retries 15
env:
REDIS_URL: redis://127.0.0.1:6379
REQUIRE_REDIS: "1"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm install --no-package-lock
- run: npm run typecheck
- name: Tests (definitions, data rules, in-process, fallback, BullMQ on Redis with the same scenario)
run: npm test
- run: npm run build
- name: The main entry does not import bullmq or ioredis
run: "! grep -E \"from ['\\\"](bullmq|ioredis)['\\\"]\" dist/index.js"
- name: llms-full.txt is up to date (run `npm run llms` and commit if this fails)
run: npm run llms && git diff --exit-code -- llms-full.txt
29 changes: 29 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Publish to GitHub Packages

on:
push:
tags: ["v*"]

permissions:
contents: read
packages: write

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
registry-url: https://npm.pkg.github.com
scope: "@mgpl-diagnostics"
- name: Tag must match package.json version
run: test "v$(node -p "require('./package.json').version")" = "${GITHUB_REF_NAME}"
# installs dependencies (incl. @mgpl-diagnostics/* peers from GitHub Packages) and builds dist/ via "prepare"
- run: npm install --no-package-lock
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56 changes: 56 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# AGENTS.md — @mgpl-diagnostics/worker-kit

Background jobs for the MGPL Diagnostics backend: typed job definitions, one queue API over three
backends (BullMQ on Redis, in-process, worker-or-standalone fallback) and the worker runner.
**A library: no server, no GraphQL, no business logic.** The api repo defines the jobs and wires
them (container + `src/worker.ts`); mail-kit queues through `enqueuer()`.

## Map

```
src/types.ts JobDefinition, JobInfo, JobState, JobContext, JobBackend, PreparedJob
src/errors.ts WorkerError (codes), JobFailure (safe message + retry flag), isJobFailure
src/define.ts defineJob, SECRET_KEY, toJsonData / prepareJob (checks in the caller), runAttempt, backoffDelay
src/queue.ts createJobQueue (enqueue, get, enqueuer, close), jobRegistry
src/inline.ts inlineBackend (wait or background, retries, backoff, retention)
src/fallback.ts fallbackBackend (primary when available, else fallback)
src/bullmq.ts "/bullmq" entry: bullmqBackend, startWorker, redisConnection, redisReady, bullJobOptions
scripts/test.ts definitions, data rules, inline, fallback, enqueuer; the same scenario on inline and BullMQ (real Redis)
```

## Rules

1. **Every backend behaves the same.** Data and results cross JSON everywhere; retries, backoff,
states, attempt counts and error messages match. Change behaviour in one backend → change it in
all, and keep the shared scenario in `scripts/test.ts` passing on inline *and* BullMQ.
2. **Failures are safe to show.** Only a `JobFailure`'s message reaches `JobInfo.error`; every other
error goes to `onError` and reads "Job failed". All attempts go through `runAttempt`.
3. **Payloads carry ids, never secrets.** `SECRET_KEY` keys are refused in the caller; do not loosen it
by default. Jobs whose data must not linger use `keepResultsMs: 0`.
4. **Checks happen in the caller** (`prepareJob`): bad data fails the request, not the worker.
5. **Zero runtime dependencies in the main entry.** Only `src/bullmq.ts` imports `bullmq`/`ioredis`
(optional peers); CI greps `dist/index.js` for it.
6. Job ids are random UUIDs; `get` returns a job only under its own definition. Ownership checks
belong to the caller (the api's services).
7. Error codes (`WorkerError.code`) and state names (`JobState`) are public: add, never rename.
8. Backends are structural (`JobBackend`); prefer that over new options when adding a queue technology.
9. Never commit secrets, `.env` files or patient data. Tests use fictional values.

## Commands

```bash
npm run typecheck && npm test && npm run build
npm run llms # regenerate llms-full.txt (CI fails when it is stale)
```

`npm test` needs Redis for the BullMQ part: `REDIS_URL`, or a local `redis-server` (started on a free
port). CI runs a Redis service with `REQUIRE_REDIS=1`.

## Repository rules

`main` takes pull requests only: squash merge, 1 code-owner review (`@BackStacked`), the `ci` check
required. Rules come from `mgpl-diagnostics/.github` (`scripts/protect-repos.mjs`).

## Licence

All rights reserved (see LICENSE).
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
146 changes: 146 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# @mgpl-diagnostics/worker-kit

Background jobs for the MGPL Diagnostics backend. You define a job once (name, processor, retries,
how long its result is kept) and start it from one queue API. Underneath, the jobs run:

- on **BullMQ over Redis**, with `startWorker` in a separate worker process (production);
- **in-process**, with no Redis (development, tests, a single server);
- or **on the worker when it is available, otherwise standalone** (`fallbackBackend`), the
behaviour mail-kit relies on.

All three behave the same: the same JSON data, the same retries and backoff, the same states and the
same error messages. `npm test` runs one scenario on each and compares the results.

- The main entry has **no runtime dependencies**. `bullmq` and `ioredis` are optional peers, used only
by the `/bullmq` entry.
- Job data is checked in the caller: it must be JSON, under a size limit, and free of secret-looking
keys (`password`, `token`, `secret`, …). Payloads carry ids; the processor looks things up.
- Processor failures are safe to show: a `JobFailure` keeps its message, anything else is logged and
shown as "Job failed".

## Install

```bash
# .npmrc: @mgpl-diagnostics:registry=https://npm.pkg.github.com
npm install @mgpl-diagnostics/worker-kit
npm install bullmq ioredis # only for the /bullmq entry
```

## Define jobs

```ts
import { defineJob, JobFailure } from "@mgpl-diagnostics/worker-kit";

export const renderInvoice = defineJob<{ orgId: string; invoiceId: string }, { base64: string }>({
name: "invoice",
queue: "documents", // default: the name
attempts: 2, // default 3, exponential backoff from backoffMs (default 2 s)
keepResultsMs: 3600_000, // finished jobs readable for an hour (default); 0 = removed at once
validate: (d) => { if (!d.invoiceId) throw new Error("invoiceId is required"); },
async process(d, ctx) { // ctx: { id, name, queue, attempt, attempts }
const invoice = await services.billing.find(d.orgId, d.invoiceId);
if (!invoice) throw new JobFailure("Invoice not found", false); // shown to the client, no retry
return { base64: await render(invoice) }; // must be JSON
},
});
```

Data and results cross JSON on every backend, so a `Date` arrives as an ISO string.

## Start and read jobs

```ts
import { createJobQueue, fallbackBackend, inlineBackend } from "@mgpl-diagnostics/worker-kit";
import { bullmqBackend } from "@mgpl-diagnostics/worker-kit/bullmq";

const backend = process.env.REDIS_URL
? bullmqBackend({ redisUrl: process.env.REDIS_URL, prefix: "mgpl" })
: inlineBackend(); // { wait: false } runs jobs in the background of this process

const jobs = createJobQueue({ backend, jobs: [renderInvoice, passwordReset] });

const job = await jobs.enqueue(renderInvoice, { orgId, invoiceId });
// → { id, name, queue, state, attempts, error, result, data, createdAt, finishedAt }
const later = await jobs.get(renderInvoice, job.id); // null if unknown, expired, or another job's id
```

Job ids are random UUIDs, but they are not a permission: check that the reader owns the job
(compare `data` with the session) before showing it.

With `inlineBackend()` (the default `wait: true`), `enqueue` resolves when the job has finished, so
the request waits. With BullMQ it resolves at once with `state: "waiting"`.

## Run the worker

```ts
import { startWorker } from "@mgpl-diagnostics/worker-kit/bullmq";

const worker = startWorker({
redisUrl: process.env.REDIS_URL!,
prefix: "mgpl",
jobs: [renderInvoice, passwordReset], // one BullMQ Worker per queue
concurrency: 4,
onError: (e, ctx) => log.error({ err: e, job: ctx }, "job failed"), // errors that are not a JobFailure
});

process.once("SIGTERM", () => worker.close()); // finishes the jobs in hand, then stops
```

A `JobFailure` with `retry: false` fails the job at once (BullMQ `UnrecoverableError`); every other
failure uses the remaining attempts.

## Worker when available, standalone otherwise

```ts
import { redisConnection } from "@mgpl-diagnostics/worker-kit/bullmq";

const connection = redisConnection(process.env.REDIS_URL!);
const jobs = createJobQueue({
backend: fallbackBackend({
primary: bullmqBackend({ connection, prefix: "mgpl" }), // available() = connection is "ready"
fallback: inlineBackend(),
onFallback: (reason, job) => log.warn({ reason, job: job.def.name }, "ran without the worker"),
// whenFails: "throw" → WorkerError WORKER_UNAVAILABLE instead
}),
jobs: [mailJob],
});
```

### With mail-kit

`jobs.enqueuer(job)` has mail-kit's `MailQueue` shape, so mail-kit can queue through worker-kit:

```ts
const mailJob = defineJob<MailJob, SendResult>({ name: "mail", keepResultsMs: 0, attempts: 5, backoffMs: 10_000,
process: (job) => mailer.deliver(job) });
const mailer = createMailer({ transport, from, queue: jobs.enqueuer(mailJob) });
```

`keepResultsMs: 0` removes a mail job as soon as it is sent, because it can hold a reset link.
In mail-kit's MailJob the link sits under `data.link`, which is allowed; a key named `token` would be refused.

## Errors

`WorkerError.code` is a public contract (add, never rename):

| Code | When |
| --- | --- |
| `WORKER_UNKNOWN_JOB` | the job is not in this queue's `jobs` (or is a different definition with the same name) |
| `WORKER_BAD_DATA` | data is not JSON, too large, has a secret-looking key, or `validate` threw; bad job/queue name |
| `WORKER_DUPLICATE_JOB` | two definitions share a name |
| `WORKER_UNAVAILABLE` | `fallbackBackend` with `whenFails: "throw"`; an in-process `enqueuer` job failed; no Redis given |
| `WORKER_CLOSED` | the queue was closed |

## Develop

```bash
npm run typecheck && npm test && npm run build
npm run llms # regenerate llms-full.txt (CI fails when it is stale)
```

`npm test` uses `REDIS_URL`, or starts a local `redis-server` when one is installed; without either the
BullMQ checks are skipped (CI sets `REQUIRE_REDIS=1` and a Redis service, so they always run there).

## Licence

All rights reserved (see [LICENSE](LICENSE)).
Loading
Loading