@12-apps/jobs 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @12-apps/jobs
2
+
3
+ Typed background jobs — retries, exponential backoff and cron — behind a
4
+ swappable driver. BullMQ/Redis in production, inline execution in tests.
5
+
6
+ Framework-free: no Prisma, no Next, no host-app types. The logger is a port.
7
+
8
+ ```ts
9
+ // where the domain lives — the import IS the registration
10
+ export const dispatchNotification = defineJob<{ notificationId: string }>({
11
+ name: "notifications.dispatch",
12
+ attempts: 5,
13
+ backoff: { type: "exponential", delayMs: 5_000 },
14
+ handle: async ({ notificationId }) => dispatchDeliveries(notificationId),
15
+ });
16
+
17
+ // a job that only ever runs on a schedule needs no binding at all
18
+ defineJob({
19
+ name: "notifications.drain",
20
+ schedule: { pattern: "*/5 * * * *", timezone: "UTC" },
21
+ handle: async () => drainPending(),
22
+ });
23
+
24
+ // at an emit site
25
+ await dispatchNotification.enqueue({ notificationId }, { dedupeKey: notificationId });
26
+
27
+ // at process start
28
+ import { createBullMqJobDriver } from "@12-apps/jobs/bullmq"; // not the barrel
29
+ configureJobs({ driver: createBullMqJobDriver({ redisUrl, logger }), logger });
30
+ await startJobWorkers(); // consumers only
31
+ ```
32
+
33
+ ## The two rules
34
+
35
+ **Payloads carry identifiers, never state.** `{ notificationId }`, not the
36
+ rendered e-mail. The database is the source of truth; the queue only decides
37
+ *when*. A payload that duplicates a row is a second copy that can disagree with
38
+ it, and it is the copy that gets acted on days later.
39
+
40
+ **Handlers are idempotent.** Delivery is at-least-once — a worker can die
41
+ between the side effect and the acknowledgement. Lean on the database's unique
42
+ constraints, not on the queue.
43
+
44
+ ## Guarantees and non-guarantees
45
+
46
+ - `enqueue` **never throws**. A queue outage returns `{ enqueued: false }` and
47
+ logs; it does not fail the request that was deferring work.
48
+ - Bounded Redis memory: completed jobs are kept a day, failed ones a week.
49
+ - Schedules are **reconciled** on start — a cron job deleted from code has its
50
+ scheduler removed from Redis, instead of firing forever at a handler that no
51
+ longer exists.
52
+ - The `inline` driver honours `attempts` but not delays or schedules, and both
53
+ omissions are logged rather than silent. It is refused in production by the
54
+ host, not by this package.
55
+
56
+ The BullMQ driver is exported from `@12-apps/jobs/bullmq`, never the barrel, so
57
+ importing `defineJob` at an emit site does not drag Redis into the bundle.
58
+
59
+ Redis must run with `maxmemory-policy noeviction` — the driver checks and
60
+ complains. See [docs/JOBS.md](../../docs/JOBS.md) for the deployment side.
@@ -0,0 +1,22 @@
1
+ import { config as baseConfig } from "@12-apps/eslint-config/base";
2
+ import testFlakiness from "eslint-plugin-test-flakiness";
3
+
4
+ /**
5
+ * The everyday DX lint for this package. Mirrors `packages/entitlements`:
6
+ * `eslint-plugin-test-flakiness` is registered with every rule OFF so that
7
+ * inline disable directives in test files RESOLVE here, while the rules
8
+ * themselves stay enforced by the repo-root flakiness lane.
9
+ */
10
+
11
+ /** @type {import("eslint").Linter.Config[]} */
12
+ export default [
13
+ ...baseConfig,
14
+ {
15
+ files: ["**/__tests__/**", "**/*.test.ts"],
16
+ plugins: { "test-flakiness": testFlakiness },
17
+ linterOptions: { reportUnusedDisableDirectives: "off" },
18
+ },
19
+ {
20
+ ignores: ["dist/**", "node_modules/**", "coverage/**"],
21
+ },
22
+ ];
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@12-apps/jobs",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Generic background-job library: a typed job registry with retries, exponential backoff and cron schedules, behind a swappable driver port (BullMQ/Redis in production, inline execution in tests). Framework-free; knows nothing about the host app's domain, ORM or transport.",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./bullmq": "./src/drivers/bullmq.ts",
9
+ "./inline": "./src/drivers/inline.ts"
10
+ },
11
+ "scripts": {
12
+ "clean": "rm -rf node_modules coverage",
13
+ "test": "node ../../scripts/vitest-with-teardown.mjs run",
14
+ "test:watch": "vitest watch",
15
+ "lint": "eslint src --max-warnings 0",
16
+ "check-types": "tsc --noEmit",
17
+ "typecheck": "tsc --noEmit"
18
+ },
19
+ "dependencies": {
20
+ "bullmq": "^5.81.2"
21
+ },
22
+ "devDependencies": {
23
+ "@12-apps/eslint-config": "^1.1.0",
24
+ "@12-apps/typescript-config": "^1.1.0",
25
+ "eslint": "^9.39.1",
26
+ "eslint-plugin-test-flakiness": "^1.4.0",
27
+ "typescript": "^5.9.2",
28
+ "vitest": "^3.2.4"
29
+ },
30
+ "engines": {
31
+ "node": ">=22.0.0"
32
+ },
33
+ "license": "MIT",
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org",
36
+ "access": "public"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/12-apps/shared-packages.git",
41
+ "directory": "packages/jobs"
42
+ },
43
+ "files": [
44
+ "src",
45
+ "dist",
46
+ "prisma",
47
+ "*.js",
48
+ "*.mjs",
49
+ "*.md"
50
+ ]
51
+ }
@@ -0,0 +1,101 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { createInlineJobDriver } from "../../drivers/inline";
4
+ import { clearJobs, defineJob, DuplicateJobError, findJob, listJobs } from "../registry";
5
+ import { configureJobs, enqueueJob, resetJobRuntime, startJobWorkers } from "../runtime";
6
+
7
+ afterEach(() => {
8
+ clearJobs();
9
+ resetJobRuntime();
10
+ });
11
+
12
+ describe("defineJob", () => {
13
+ it("registers the definition under its name", () => {
14
+ const job = defineJob({ name: "a.job", handle: () => Promise.resolve() });
15
+
16
+ expect(job.name).toBe("a.job");
17
+ expect(findJob("a.job")).toBe(job.definition);
18
+ expect(listJobs()).toHaveLength(1);
19
+ });
20
+
21
+ it("refuses a duplicate name rather than replacing the handler", () => {
22
+ defineJob({ name: "a.job", handle: () => Promise.resolve() });
23
+
24
+ expect(() => defineJob({ name: "a.job", handle: () => Promise.resolve() })).toThrow(
25
+ DuplicateJobError,
26
+ );
27
+ });
28
+ });
29
+
30
+ describe("enqueue", () => {
31
+ it("runs the handler through the installed driver", async () => {
32
+ const handle = vi.fn().mockResolvedValue(undefined);
33
+ const job = defineJob<{ id: string }>({ name: "a.job", handle });
34
+ configureJobs({ driver: createInlineJobDriver({ logger: silentLogger() }) });
35
+
36
+ const result = await job.enqueue({ id: "x" });
37
+
38
+ expect(result).toEqual({ enqueued: true });
39
+ expect(handle).toHaveBeenCalledWith(
40
+ { id: "x" },
41
+ expect.objectContaining({ attempt: 1, maxAttempts: 1 }),
42
+ );
43
+ });
44
+
45
+ it("reports rather than throws when no driver is configured", async () => {
46
+ const job = defineJob({ name: "a.job", handle: () => Promise.resolve() });
47
+
48
+ await expect(job.enqueue()).resolves.toEqual({
49
+ enqueued: false,
50
+ reason: "no-driver",
51
+ });
52
+ });
53
+
54
+ it("reports rather than throws when the driver itself fails", async () => {
55
+ const definition = { name: "a.job", handle: () => Promise.resolve() };
56
+ configureJobs({
57
+ logger: silentLogger(),
58
+ driver: {
59
+ kind: "broken",
60
+ enqueue: () => Promise.reject(new Error("redis is down")),
61
+ start: () => Promise.resolve(),
62
+ stop: () => Promise.resolve(),
63
+ },
64
+ });
65
+
66
+ await expect(enqueueJob(definition, undefined, {})).resolves.toEqual({
67
+ enqueued: false,
68
+ reason: "error",
69
+ });
70
+ });
71
+ });
72
+
73
+ describe("startJobWorkers", () => {
74
+ it("starts every registered job once, ignoring a second call", async () => {
75
+ defineJob({ name: "a.job", handle: () => Promise.resolve() });
76
+ const start = vi.fn().mockResolvedValue(undefined);
77
+ configureJobs({
78
+ logger: silentLogger(),
79
+ driver: {
80
+ kind: "spy",
81
+ enqueue: () => Promise.resolve({ enqueued: true }),
82
+ start,
83
+ stop: () => Promise.resolve(),
84
+ },
85
+ });
86
+
87
+ await startJobWorkers();
88
+ await startJobWorkers();
89
+
90
+ expect(start).toHaveBeenCalledTimes(1);
91
+ expect(start.mock.calls[0]?.[0]).toHaveLength(1);
92
+ });
93
+
94
+ it("refuses to start with no driver configured", async () => {
95
+ await expect(startJobWorkers()).rejects.toThrow("no driver configured");
96
+ });
97
+ });
98
+
99
+ function silentLogger() {
100
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
101
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The job registry: `defineJob` at module scope, jobs collected by import.
3
+ *
4
+ * Same open/closed seam as the notification generators — a domain module
5
+ * declares its jobs next to the code they belong to, and the worker bootstrap
6
+ * only has to import those modules. Nothing central lists them.
7
+ */
8
+
9
+ import type {
10
+ AnyJobDefinition,
11
+ EnqueueOptions,
12
+ EnqueueResult,
13
+ JobDefinition,
14
+ } from "./types";
15
+
16
+ /** A defined job: its declaration, plus the typed way to enqueue one. */
17
+ export interface RegisteredJob<TPayload> {
18
+ readonly name: string;
19
+ readonly definition: JobDefinition<TPayload>;
20
+ /** Defer one run. Never throws — see {@link EnqueueResult}. */
21
+ enqueue(payload: TPayload, options?: EnqueueOptions): Promise<EnqueueResult>;
22
+ }
23
+
24
+ const registry = new Map<string, AnyJobDefinition>();
25
+
26
+ /** Raised for a duplicate job name — always a programming error. */
27
+ export class DuplicateJobError extends Error {
28
+ constructor(name: string) {
29
+ super(`A job named "${name}" is already defined.`);
30
+ this.name = "DuplicateJobError";
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Declare a job and register it under its name.
36
+ *
37
+ * Throws on a duplicate name rather than replacing: two modules quietly
38
+ * claiming one name means one of them never runs, and a schedule installed
39
+ * under that name would fire the wrong handler. (The notification registry
40
+ * replaces on re-register, which is right for *content* generators and wrong
41
+ * for *execution*.)
42
+ */
43
+ export function defineJob<TPayload = void>(
44
+ definition: JobDefinition<TPayload>,
45
+ ): RegisteredJob<TPayload> {
46
+ if (registry.has(definition.name)) throw new DuplicateJobError(definition.name);
47
+ registry.set(definition.name, definition as unknown as AnyJobDefinition);
48
+
49
+ return {
50
+ name: definition.name,
51
+ definition,
52
+ async enqueue(payload, options = {}) {
53
+ // Imported lazily: the runtime imports this module, and a static cycle
54
+ // would leave one of the two half-initialized at first use.
55
+ const { enqueueJob } = await import("./runtime");
56
+ return enqueueJob(definition as unknown as AnyJobDefinition, payload, options);
57
+ },
58
+ };
59
+ }
60
+
61
+ /** Every registered definition, in declaration order. */
62
+ export function listJobs(): readonly AnyJobDefinition[] {
63
+ return [...registry.values()];
64
+ }
65
+
66
+ /** One definition by name, or `undefined`. */
67
+ export function findJob(name: string): AnyJobDefinition | undefined {
68
+ return registry.get(name);
69
+ }
70
+
71
+ /** Test-only: drop every registration (isolation between suites). */
72
+ export function clearJobs(): void {
73
+ registry.clear();
74
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The process-wide job runtime: which driver is installed, and the enqueue /
3
+ * start / stop entry points that everything else goes through.
4
+ *
5
+ * A process is either a PRODUCER (the web server: it enqueues, it does not
6
+ * consume), a CONSUMER (the worker: it does both), or neither (a unit test).
7
+ * `configureJobs` installs the driver; `startJobWorkers` is the extra step a
8
+ * consumer takes.
9
+ */
10
+
11
+ import { listJobs } from "./registry";
12
+ import type {
13
+ AnyJobDefinition,
14
+ EnqueueOptions,
15
+ EnqueueResult,
16
+ JobDriver,
17
+ JobLogger,
18
+ } from "./types";
19
+
20
+ /** Fallback logger — used until the host installs its own. */
21
+ const consoleLogger: JobLogger = {
22
+ info: (message, ...meta) => console.info(`[jobs] ${message}`, ...meta),
23
+ warn: (message, ...meta) => console.warn(`[jobs] ${message}`, ...meta),
24
+ error: (message, ...meta) => console.error(`[jobs] ${message}`, ...meta),
25
+ };
26
+
27
+ let driver: JobDriver | null = null;
28
+ let logger: JobLogger = consoleLogger;
29
+ let started = false;
30
+
31
+ /** Install the driver (and optionally the host's logger) for this process. */
32
+ export function configureJobs(options: {
33
+ driver: JobDriver;
34
+ logger?: JobLogger;
35
+ }): void {
36
+ driver = options.driver;
37
+ if (options.logger) logger = options.logger;
38
+ logger.info(`runtime configured with the "${options.driver.kind}" driver`);
39
+ }
40
+
41
+ /** The installed driver, or `null` in a process that never configured one. */
42
+ export function getJobDriver(): JobDriver | null {
43
+ return driver;
44
+ }
45
+
46
+ /** The logger the runtime and drivers write through. */
47
+ export function getJobLogger(): JobLogger {
48
+ return logger;
49
+ }
50
+
51
+ /**
52
+ * Defer one run of `definition`.
53
+ *
54
+ * **Never throws.** Every caller of this has just committed a durable row —
55
+ * a notification delivery, a due subscription cycle, a stock movement — and
56
+ * the enqueue is only the fast path to acting on it. Redis being down must
57
+ * degrade that to "a sweep will pick it up in a few minutes", not fail the
58
+ * checkout or the webhook that triggered it. The failure is logged loudly and
59
+ * reported in the result for callers that care.
60
+ */
61
+ export async function enqueueJob(
62
+ definition: AnyJobDefinition,
63
+ payload: unknown,
64
+ options: EnqueueOptions = {},
65
+ ): Promise<EnqueueResult> {
66
+ if (!driver) {
67
+ logger.warn(
68
+ `"${definition.name}" was not enqueued: no driver is configured in this process.`,
69
+ );
70
+ return { enqueued: false, reason: "no-driver" };
71
+ }
72
+ try {
73
+ return await driver.enqueue(definition, payload, options);
74
+ } catch (error) {
75
+ logger.error(`enqueue of "${definition.name}" failed:`, error);
76
+ return { enqueued: false, reason: "error" };
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Start consuming every registered job and install their cron schedules.
82
+ *
83
+ * Idempotent: a second call is a no-op, so a bootstrap that runs twice (Next
84
+ * can evaluate `instrumentation.ts` more than once in dev) cannot double-
85
+ * consume.
86
+ */
87
+ export async function startJobWorkers(): Promise<void> {
88
+ if (!driver) throw new Error("startJobWorkers(): no driver configured.");
89
+ if (started) {
90
+ logger.warn("startJobWorkers() called twice; ignoring the second call.");
91
+ return;
92
+ }
93
+ started = true;
94
+
95
+ const definitions = listJobs();
96
+ await driver.start(definitions);
97
+ const scheduled = definitions.filter((job) => job.schedule).length;
98
+ logger.info(
99
+ `workers started: ${definitions.length} job(s), ${scheduled} on a schedule.`,
100
+ );
101
+ }
102
+
103
+ /** Stop consuming and release the driver's connections. */
104
+ export async function stopJobs(): Promise<void> {
105
+ if (!driver) return;
106
+ await driver.stop();
107
+ started = false;
108
+ logger.info("workers stopped.");
109
+ }
110
+
111
+ /** Test-only: forget the installed driver and the started flag. */
112
+ export function resetJobRuntime(): void {
113
+ driver = null;
114
+ logger = consoleLogger;
115
+ started = false;
116
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Core types of the background-job library.
3
+ *
4
+ * Three layers, each replaceable without touching the others:
5
+ * - DEFINITIONS describe a unit of deferred work: its name, its retry
6
+ * policy, its optional cron schedule, and the handler that runs it.
7
+ * - The REGISTRY collects definitions at import time (the same open/closed
8
+ * seam the notification generators use) so a worker process can start
9
+ * every job by importing the modules that define them.
10
+ * - A DRIVER executes them. BullMQ/Redis in production, inline in tests.
11
+ *
12
+ * Nothing here imports Redis, Prisma, or the host app. A job handler receives
13
+ * a plain payload and is expected to re-read whatever it needs from the
14
+ * database — see the payload rule below.
15
+ */
16
+
17
+ /**
18
+ * The logging port. Structurally satisfied by a winston logger (what the host
19
+ * app has) and by `console` (what a test wants), so the library needs no
20
+ * logging dependency of its own.
21
+ */
22
+ export interface JobLogger {
23
+ info(message: string, ...meta: unknown[]): void;
24
+ warn(message: string, ...meta: unknown[]): void;
25
+ error(message: string, ...meta: unknown[]): void;
26
+ }
27
+
28
+ /** Retry spacing after a failed attempt. */
29
+ export interface JobBackoff {
30
+ /**
31
+ * `exponential` doubles `delayMs` per attempt (5s → 10s → 20s → …), which is
32
+ * what a flaky provider call wants. `fixed` waits `delayMs` every time.
33
+ */
34
+ type: "exponential" | "fixed";
35
+ delayMs: number;
36
+ }
37
+
38
+ /** A cron schedule for a repeatable job. */
39
+ export interface JobSchedule {
40
+ /** Standard 5-field cron expression. */
41
+ pattern: string;
42
+ /**
43
+ * IANA timezone the pattern is evaluated in. Defaults to UTC — a schedule
44
+ * that means "03:00 in São Paulo" must say so, because a server's local
45
+ * zone is not a product decision.
46
+ */
47
+ timezone?: string;
48
+ }
49
+
50
+ /** What a handler is told about the attempt it is running in. */
51
+ export interface JobContext {
52
+ /** The driver's id for this run. Useful in logs; never a business key. */
53
+ runId: string;
54
+ /** 1-based. `attempt === maxAttempts` means this is the last chance. */
55
+ attempt: number;
56
+ maxAttempts: number;
57
+ logger: JobLogger;
58
+ }
59
+
60
+ /** The unit of work itself. Throwing schedules a retry; returning succeeds. */
61
+ export type JobHandler<TPayload> = (
62
+ payload: TPayload,
63
+ context: JobContext,
64
+ ) => Promise<void>;
65
+
66
+ /**
67
+ * A job's declaration.
68
+ *
69
+ * ## The payload rule
70
+ *
71
+ * A payload carries IDENTIFIERS, never state. `{ notificationId }`, not the
72
+ * rendered e-mail; `{ subscriptionId, periodStart }`, not the amount to
73
+ * charge. Two reasons, and both are load-bearing:
74
+ *
75
+ * 1. **Redis is not the source of truth.** The database is. A payload that
76
+ * duplicates a row's contents is a second copy that can disagree with it
77
+ * — and the copy is the one that gets acted on, days later, after the row
78
+ * changed. Re-reading inside the handler is always correct.
79
+ * 2. **Redis can be lost.** A flushed or evicted queue must cost a delayed
80
+ * run, not a lost or corrupted business fact. Every job here is paired
81
+ * with a durable row that a sweep can find again.
82
+ *
83
+ * ## Idempotency
84
+ *
85
+ * At-least-once delivery is the contract. A handler MUST tolerate running
86
+ * twice on the same payload — the driver can redeliver after a worker dies
87
+ * between the side effect and the acknowledgement, and a retry after a
88
+ * timeout may race the original. Lean on the database's unique constraints
89
+ * for this, not on the queue.
90
+ */
91
+ export interface JobDefinition<TPayload = void> {
92
+ /** Dot-namespaced and stable: it is the wire key and the scheduler id. */
93
+ name: string;
94
+ /**
95
+ * Which queue carries it. One queue ("default") for everything is the right
96
+ * shape at this scale — one worker, one pair of Redis connections, one
97
+ * dashboard. Move a noisy or slow job onto its own queue when it starts
98
+ * starving the others; no call site changes when you do.
99
+ */
100
+ queue?: string;
101
+ /** Total attempts including the first. Defaults to 1 (no retry). */
102
+ attempts?: number;
103
+ backoff?: JobBackoff;
104
+ /** Present ⇒ the job also runs on a schedule, with no payload. */
105
+ schedule?: JobSchedule;
106
+ /**
107
+ * Per-queue worker concurrency. Set it on any job in the queue; the highest
108
+ * STATED value wins, and the driver's default applies only when no job on
109
+ * the queue states one — so `concurrency: 1` really does mean single-flight
110
+ * rather than being raised back to the default.
111
+ */
112
+ concurrency?: number;
113
+ handle: JobHandler<TPayload>;
114
+ }
115
+
116
+ /** A definition with its payload type erased — what registries and drivers hold. */
117
+ export type AnyJobDefinition = JobDefinition<never>;
118
+
119
+ /** Per-enqueue overrides. */
120
+ export interface EnqueueOptions {
121
+ /**
122
+ * Collapses duplicates: while a job with this key is waiting, delayed or
123
+ * active, enqueueing it again is a no-op. Scope it to the work, not the
124
+ * caller — `notification:<id>`, `low-stock:<itemId>:<date>`.
125
+ *
126
+ * NOT a durability mechanism. The key is forgotten once the job completes
127
+ * and is cleaned up, so it stops accidental double-sends within a window,
128
+ * not double-processing across days. Business idempotency stays in the
129
+ * database.
130
+ */
131
+ dedupeKey?: string;
132
+ /** Run no earlier than this many milliseconds from now. */
133
+ delayMs?: number;
134
+ }
135
+
136
+ /** Why an enqueue did not reach a queue. */
137
+ export type EnqueueSkipReason = "no-driver" | "duplicate" | "error";
138
+
139
+ /**
140
+ * The result of an enqueue.
141
+ *
142
+ * Enqueueing NEVER throws (see `enqueueJob`): a queue outage must not take
143
+ * down the request that was merely trying to defer some work. The caller has
144
+ * already committed the durable row; a sweep will find it.
145
+ */
146
+ export interface EnqueueResult {
147
+ enqueued: boolean;
148
+ reason?: EnqueueSkipReason;
149
+ }
150
+
151
+ /**
152
+ * The driver port. `inline` and `bullmq` implement it; a third (SQS, pg-boss)
153
+ * would need no change above this line.
154
+ */
155
+ export interface JobDriver {
156
+ readonly kind: string;
157
+ /** Hand one unit of work to the backend. Throws only on a real backend fault. */
158
+ enqueue(
159
+ definition: AnyJobDefinition,
160
+ payload: unknown,
161
+ options: EnqueueOptions,
162
+ ): Promise<EnqueueResult>;
163
+ /**
164
+ * Begin consuming, and install the cron schedules of every definition that
165
+ * declares one. Idempotent: calling it twice must not double-consume or
166
+ * duplicate a schedule.
167
+ */
168
+ start(definitions: readonly AnyJobDefinition[]): Promise<void>;
169
+ /** Stop consuming and release connections, letting in-flight jobs finish. */
170
+ stop(): Promise<void>;
171
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { AnyJobDefinition } from "../../core/types";
4
+ import { __testables } from "../bullmq";
5
+
6
+ /**
7
+ * The queue-concurrency rule. Pinned on its own because getting it wrong is
8
+ * SILENT: a job that asked for single-flight would simply run concurrently,
9
+ * and nothing would fail — the sweeps would just start racing each other again.
10
+ */
11
+ const { resolveConcurrency, DEFAULT_CONCURRENCY } = __testables;
12
+
13
+ function job(overrides: Partial<AnyJobDefinition> = {}): AnyJobDefinition {
14
+ return { name: "a.job", handle: () => Promise.resolve(), ...overrides };
15
+ }
16
+
17
+ describe("resolveConcurrency", () => {
18
+ it("falls back to the default when no job on the queue states one", () => {
19
+ expect(resolveConcurrency([job(), job({ name: "b.job" })])).toBe(DEFAULT_CONCURRENCY);
20
+ });
21
+
22
+ it("honours a stated 1 instead of raising it to the default", () => {
23
+ // The regression this exists for: `Math.max(DEFAULT, ...)` silently turns
24
+ // single-flight back into the default and undoes the guarantee.
25
+ expect(resolveConcurrency([job({ concurrency: 1 })])).toBe(1);
26
+ });
27
+
28
+ it("takes the highest STATED value when several are given", () => {
29
+ expect(
30
+ resolveConcurrency([job({ concurrency: 1 }), job({ name: "b.job", concurrency: 4 })]),
31
+ ).toBe(4);
32
+ });
33
+
34
+ it("ignores a stated value alongside unstated ones rather than averaging in the default", () => {
35
+ expect(resolveConcurrency([job({ concurrency: 2 }), job({ name: "b.job" })])).toBe(2);
36
+ });
37
+
38
+ it("ignores a nonsensical value", () => {
39
+ expect(resolveConcurrency([job({ concurrency: 0 })])).toBe(DEFAULT_CONCURRENCY);
40
+ expect(resolveConcurrency([job({ concurrency: -3 })])).toBe(DEFAULT_CONCURRENCY);
41
+ });
42
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import type { AnyJobDefinition } from "../../core/types";
4
+ import { createInlineJobDriver } from "../inline";
5
+
6
+ function silentLogger() {
7
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
8
+ }
9
+
10
+ function definition(overrides: Partial<AnyJobDefinition> = {}): AnyJobDefinition {
11
+ return { name: "a.job", handle: () => Promise.resolve(), ...overrides };
12
+ }
13
+
14
+ describe("inline driver", () => {
15
+ it("awaits the handler so a test sees the side effect", async () => {
16
+ const seen: string[] = [];
17
+ const driver = createInlineJobDriver({ logger: silentLogger() });
18
+
19
+ await driver.enqueue(
20
+ definition({ handle: (payload) => {
21
+ seen.push(payload as string);
22
+ return Promise.resolve();
23
+ } }),
24
+ "one",
25
+ {},
26
+ );
27
+
28
+ expect(seen).toEqual(["one"]);
29
+ });
30
+
31
+ it("retries up to `attempts` and records the run", async () => {
32
+ const handle = vi
33
+ .fn()
34
+ .mockRejectedValueOnce(new Error("transient"))
35
+ .mockResolvedValueOnce(undefined);
36
+ const driver = createInlineJobDriver({ logger: silentLogger() });
37
+
38
+ await driver.enqueue(definition({ attempts: 3, handle }), undefined, {});
39
+
40
+ expect(handle).toHaveBeenCalledTimes(2);
41
+ expect(driver.runs).toEqual([
42
+ { name: "a.job", payload: undefined, attempts: 2, error: undefined },
43
+ ]);
44
+ });
45
+
46
+ it("swallows a handler that fails every attempt, logging it once", async () => {
47
+ const logger = silentLogger();
48
+ const handle = vi.fn().mockRejectedValue(new Error("permanent"));
49
+ const driver = createInlineJobDriver({ logger });
50
+
51
+ await expect(
52
+ driver.enqueue(definition({ attempts: 2, handle }), undefined, {}),
53
+ ).resolves.toEqual({ enqueued: true });
54
+
55
+ expect(handle).toHaveBeenCalledTimes(2);
56
+ expect(logger.error).toHaveBeenCalledTimes(1);
57
+ expect(driver.runs[0]?.error).toBeInstanceOf(Error);
58
+ });
59
+
60
+ it("warns that a delay is ignored instead of silently dropping it", async () => {
61
+ const logger = silentLogger();
62
+ const driver = createInlineJobDriver({ logger });
63
+
64
+ await driver.enqueue(definition(), undefined, { delayMs: 3_000 });
65
+
66
+ expect(logger.warn).toHaveBeenCalledWith(
67
+ expect.stringContaining("ignored a 3000ms delay"),
68
+ );
69
+ });
70
+
71
+ it("warns that registered schedules will never fire", async () => {
72
+ const logger = silentLogger();
73
+ const driver = createInlineJobDriver({ logger });
74
+
75
+ await driver.start([definition({ schedule: { pattern: "0 * * * *" } })]);
76
+
77
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("a.job"));
78
+ });
79
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { InvalidRedisUrlError, parseRedisUrl } from "../redis-url";
4
+
5
+ describe("parseRedisUrl", () => {
6
+ it("defaults host, port and database", () => {
7
+ expect(parseRedisUrl("redis://localhost")).toEqual({
8
+ host: "localhost",
9
+ port: 6379,
10
+ username: undefined,
11
+ password: undefined,
12
+ db: undefined,
13
+ tls: undefined,
14
+ maxRetriesPerRequest: null,
15
+ enableReadyCheck: false,
16
+ });
17
+ });
18
+
19
+ it("reads credentials, port and database from the URL", () => {
20
+ const parsed = parseRedisUrl("redis://user:p%40ss@redis:6380/3");
21
+
22
+ expect(parsed).toMatchObject({
23
+ host: "redis",
24
+ port: 6380,
25
+ username: "user",
26
+ password: "p@ss",
27
+ db: 3,
28
+ });
29
+ });
30
+
31
+ it("enables TLS for rediss://", () => {
32
+ expect(parseRedisUrl("rediss://redis:6379").tls).toEqual({});
33
+ });
34
+
35
+ it("always pins the two options BullMQ requires", () => {
36
+ const parsed = parseRedisUrl("redis://redis:6379/1");
37
+
38
+ expect(parsed.maxRetriesPerRequest).toBeNull();
39
+ expect(parsed.enableReadyCheck).toBe(false);
40
+ });
41
+
42
+ it("rejects a non-Redis protocol", () => {
43
+ expect(() => parseRedisUrl("http://redis:6379")).toThrow(InvalidRedisUrlError);
44
+ });
45
+
46
+ it("rejects a malformed URL", () => {
47
+ expect(() => parseRedisUrl("not a url")).toThrow(InvalidRedisUrlError);
48
+ });
49
+
50
+ it("never puts the URL (which can carry a password) in the error message", () => {
51
+ expect(() => parseRedisUrl("http://user:hunter2@redis:6379")).toThrow(
52
+ expect.objectContaining({
53
+ message: expect.not.stringContaining("hunter2") as unknown as string,
54
+ }),
55
+ );
56
+ });
57
+ });
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The BULLMQ driver — the production path.
3
+ *
4
+ * ## What Redis is, and is not
5
+ *
6
+ * It is the EXECUTOR: what runs next, how many at once, when to retry. It is
7
+ * never the ledger. Payloads carry ids (see `JobDefinition`'s payload rule),
8
+ * every handler re-reads its row, and every job is paired with a durable table
9
+ * a sweep can re-derive the work from. Losing Redis therefore costs a delayed
10
+ * run — not a lost charge, and not a double one.
11
+ *
12
+ * That split is what makes the enqueue-outside-the-transaction problem
13
+ * survivable. A Prisma commit followed by an enqueue is not atomic: crash in
14
+ * between and the job never lands. The paired sweep is the answer — the row is
15
+ * committed, so the next tick finds it.
16
+ *
17
+ * ## Redis configuration this driver depends on
18
+ *
19
+ * `maxmemory-policy` MUST be `noeviction`. Under any `allkeys-*` policy Redis
20
+ * will evict live queue keys under pressure and BullMQ loses jobs silently.
21
+ * The driver checks on start and complains loudly rather than trusting it.
22
+ * Persistence (AOF) should be on: a restart otherwise drops delayed jobs, and
23
+ * the schedules are re-installed on boot but pending retries are not.
24
+ */
25
+
26
+ import { Queue, UnrecoverableError, Worker, type JobsOptions } from "bullmq";
27
+
28
+ import type {
29
+ AnyJobDefinition,
30
+ EnqueueOptions,
31
+ EnqueueResult,
32
+ JobContext,
33
+ JobDriver,
34
+ JobLogger,
35
+ } from "../core/types";
36
+
37
+ import { parseRedisUrl, type RedisConnectionOptions } from "./redis-url";
38
+
39
+ /** The queue a definition lands on when it does not name one. */
40
+ const DEFAULT_QUEUE = "default";
41
+ /** Worker concurrency when no definition in the queue asks for more. */
42
+ const DEFAULT_CONCURRENCY = 5;
43
+
44
+ /**
45
+ * Retention. Bounded on purpose: an unbounded completed-set is the classic way
46
+ * a small Redis fills up and starts refusing writes. A day of successes is
47
+ * enough to answer "did it run?"; a week of failures is enough to debug one.
48
+ */
49
+ const RETENTION: Pick<JobsOptions, "removeOnComplete" | "removeOnFail"> = {
50
+ removeOnComplete: { age: 24 * 3600, count: 1_000 },
51
+ removeOnFail: { age: 7 * 24 * 3600, count: 5_000 },
52
+ };
53
+
54
+ /** The one ioredis command this driver reads outside BullMQ's own surface. */
55
+ interface RedisConfigReader {
56
+ config(command: "GET", parameter: string): Promise<unknown>;
57
+ }
58
+
59
+ export interface BullMqJobDriverOptions {
60
+ /** `redis://[user:pass@]host:port[/db]`. */
61
+ redisUrl: string;
62
+ logger: JobLogger;
63
+ /**
64
+ * Key prefix, so one Redis can carry several environments without a staging
65
+ * worker consuming production's jobs.
66
+ */
67
+ prefix?: string;
68
+ }
69
+
70
+ /** Everything the driver's helpers need, threaded instead of closed over. */
71
+ interface DriverState {
72
+ connection: RedisConnectionOptions;
73
+ logger: JobLogger;
74
+ prefix?: string;
75
+ queues: Map<string, Queue>;
76
+ workers: Worker[];
77
+ }
78
+
79
+ /** Turn a definition's retry policy into BullMQ's per-job options. */
80
+ function jobOptionsFor(
81
+ definition: AnyJobDefinition,
82
+ enqueueOptions: EnqueueOptions,
83
+ ): JobsOptions {
84
+ const options: JobsOptions = {
85
+ attempts: Math.max(1, definition.attempts ?? 1),
86
+ ...RETENTION,
87
+ };
88
+ if (definition.backoff) {
89
+ options.backoff = {
90
+ type: definition.backoff.type,
91
+ delay: definition.backoff.delayMs,
92
+ };
93
+ }
94
+ if (enqueueOptions.delayMs) options.delay = enqueueOptions.delayMs;
95
+ if (enqueueOptions.dedupeKey) {
96
+ // BullMQ's own deduplication (not a hijacked `jobId`): the entry is
97
+ // released when the job finishes, so a later, legitimately-identical run
98
+ // is not swallowed by a retained completed job.
99
+ options.deduplication = { id: enqueueOptions.dedupeKey };
100
+ }
101
+ return options;
102
+ }
103
+
104
+ function queueFor(state: DriverState, name: string): Queue {
105
+ const existing = state.queues.get(name);
106
+ if (existing) return existing;
107
+
108
+ const queue = new Queue(name, {
109
+ connection: state.connection,
110
+ ...(state.prefix ? { prefix: state.prefix } : {}),
111
+ defaultJobOptions: RETENTION,
112
+ });
113
+ // Without a listener an emitted 'error' is an unhandled exception that takes
114
+ // the process down — a Redis blip must not kill the web server.
115
+ queue.on("error", (error) => state.logger.error(`queue "${name}" error:`, error));
116
+ state.queues.set(name, queue);
117
+ return queue;
118
+ }
119
+
120
+ /**
121
+ * Warn when Redis is configured to evict. Best-effort: managed providers
122
+ * routinely forbid `CONFIG GET`, and an unanswerable check is not a reason to
123
+ * refuse to start.
124
+ */
125
+ async function checkEvictionPolicy(state: DriverState, queue: Queue): Promise<void> {
126
+ try {
127
+ // `CONFIG` is an ioredis command BullMQ's narrower client type does not
128
+ // advertise. Reached through a structural type rather than a blanket cast,
129
+ // and guarded — this is a diagnostic, not a dependency.
130
+ const client = (await queue.client) as unknown as Partial<RedisConfigReader>;
131
+ if (typeof client.config !== "function") return;
132
+ const config = await client.config("GET", "maxmemory-policy");
133
+ const policy = Array.isArray(config) ? String(config[1]) : "";
134
+ if (policy && policy !== "noeviction") {
135
+ state.logger.error(
136
+ `Redis maxmemory-policy is "${policy}", not "noeviction" — queued jobs CAN be evicted and lost. Fix the Redis config.`,
137
+ );
138
+ }
139
+ } catch {
140
+ state.logger.info(
141
+ "could not read Redis maxmemory-policy (provider restricts CONFIG).",
142
+ );
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Install every cron schedule and REMOVE the ones no longer declared in code.
148
+ *
149
+ * Without the removal half, a renamed or deleted cron job keeps firing forever
150
+ * from Redis — the schedule outlives the deploy that created it, and lands on a
151
+ * handler that no longer exists.
152
+ */
153
+ async function reconcileSchedules(
154
+ state: DriverState,
155
+ queue: Queue,
156
+ definitions: readonly AnyJobDefinition[],
157
+ ): Promise<void> {
158
+ const wanted = new Map(
159
+ definitions.filter((job) => job.schedule).map((job) => [job.name, job]),
160
+ );
161
+
162
+ for (const [name, job] of wanted) {
163
+ const schedule = job.schedule;
164
+ if (!schedule) continue;
165
+ await queue.upsertJobScheduler(
166
+ name,
167
+ { pattern: schedule.pattern, tz: schedule.timezone ?? "UTC" },
168
+ { name, data: {}, opts: jobOptionsFor(job, {}) },
169
+ );
170
+ state.logger.info(
171
+ `schedule installed: ${name} (${schedule.pattern} ${schedule.timezone ?? "UTC"})`,
172
+ );
173
+ }
174
+
175
+ for (const scheduler of await queue.getJobSchedulers()) {
176
+ if (wanted.has(scheduler.key)) continue;
177
+ await queue.removeJobScheduler(scheduler.key);
178
+ state.logger.warn(
179
+ `removed stale schedule "${scheduler.key}" (no longer defined in code).`,
180
+ );
181
+ }
182
+ }
183
+
184
+ /**
185
+ * A queue's worker concurrency.
186
+ *
187
+ * The default applies only when NO definition on the queue asked for a
188
+ * specific number. A stated `concurrency: 1` means single-flight and must be
189
+ * honoured — taking `max(default, …)` would quietly raise it back to the
190
+ * default and undo the very property the caller asked for.
191
+ */
192
+ function resolveConcurrency(group: readonly AnyJobDefinition[]): number {
193
+ const stated = group
194
+ .map((definition) => definition.concurrency)
195
+ .filter((value): value is number => typeof value === "number" && value > 0);
196
+ return stated.length > 0 ? Math.max(...stated) : DEFAULT_CONCURRENCY;
197
+ }
198
+
199
+ /** Consume one queue, dispatching by job name to the definitions it carries. */
200
+ function startWorker(
201
+ state: DriverState,
202
+ queueName: string,
203
+ group: readonly AnyJobDefinition[],
204
+ ): Worker {
205
+ const byName = new Map(group.map((definition) => [definition.name, definition]));
206
+ const concurrency = resolveConcurrency(group);
207
+
208
+ const worker = new Worker(
209
+ queueName,
210
+ async (job) => {
211
+ const definition = byName.get(job.name);
212
+ if (!definition) {
213
+ // A job left in Redis by a previous deploy whose handler is gone.
214
+ // Retrying cannot help, so fail it terminally instead of burning every
215
+ // attempt on it.
216
+ throw new UnrecoverableError(`No handler registered for job "${job.name}".`);
217
+ }
218
+ const context: JobContext = {
219
+ runId: job.id ?? `${job.name}:unknown`,
220
+ attempt: job.attemptsMade + 1,
221
+ maxAttempts: definition.attempts ?? 1,
222
+ logger: state.logger,
223
+ };
224
+ await definition.handle(job.data as never, context);
225
+ },
226
+ {
227
+ connection: state.connection,
228
+ concurrency,
229
+ ...(state.prefix ? { prefix: state.prefix } : {}),
230
+ },
231
+ );
232
+
233
+ worker.on("failed", (job, error) => {
234
+ const attempts = job ? `${job.attemptsMade}/${job.opts.attempts ?? 1}` : "?";
235
+ state.logger.error(
236
+ `job "${job?.name ?? queueName}" failed (attempt ${attempts}):`,
237
+ error,
238
+ );
239
+ });
240
+ worker.on("error", (error) =>
241
+ state.logger.error(`worker "${queueName}" error:`, error),
242
+ );
243
+ return worker;
244
+ }
245
+
246
+ /** Definitions bucketed by the queue that carries them. */
247
+ function groupByQueue(
248
+ definitions: readonly AnyJobDefinition[],
249
+ ): Map<string, AnyJobDefinition[]> {
250
+ const byQueue = new Map<string, AnyJobDefinition[]>();
251
+ for (const definition of definitions) {
252
+ const name = definition.queue ?? DEFAULT_QUEUE;
253
+ const group = byQueue.get(name);
254
+ if (group) group.push(definition);
255
+ else byQueue.set(name, [definition]);
256
+ }
257
+ return byQueue;
258
+ }
259
+
260
+ export function createBullMqJobDriver(options: BullMqJobDriverOptions): JobDriver {
261
+ const state: DriverState = {
262
+ connection: parseRedisUrl(options.redisUrl),
263
+ logger: options.logger,
264
+ prefix: options.prefix,
265
+ queues: new Map(),
266
+ workers: [],
267
+ };
268
+
269
+ return {
270
+ kind: "bullmq",
271
+
272
+ async enqueue(definition, payload, enqueueOptions): Promise<EnqueueResult> {
273
+ const queue = queueFor(state, definition.queue ?? DEFAULT_QUEUE);
274
+ await queue.add(definition.name, payload, jobOptionsFor(definition, enqueueOptions));
275
+ return { enqueued: true };
276
+ },
277
+
278
+ async start(definitions): Promise<void> {
279
+ for (const [queueName, group] of groupByQueue(definitions)) {
280
+ state.workers.push(startWorker(state, queueName, group));
281
+ const queue = queueFor(state, queueName);
282
+ await checkEvictionPolicy(state, queue);
283
+ await reconcileSchedules(state, queue, group);
284
+ }
285
+ },
286
+
287
+ async stop(): Promise<void> {
288
+ // Workers first: `close()` waits for in-flight jobs, and closing the
289
+ // queues underneath a running handler would fail its final update.
290
+ await Promise.all(state.workers.map((worker) => worker.close()));
291
+ state.workers.length = 0;
292
+ await Promise.all([...state.queues.values()].map((queue) => queue.close()));
293
+ state.queues.clear();
294
+ },
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Internals exposed for tests only — the concurrency rule is silent when
300
+ * wrong, so it is pinned directly rather than inferred from a live Worker.
301
+ */
302
+ export const __testables = { resolveConcurrency, DEFAULT_CONCURRENCY };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The INLINE driver: run the handler in the calling process, immediately.
3
+ *
4
+ * For unit and integration tests (which boot PGlite and must not need a Redis
5
+ * container) and for local development without `docker compose up`. It is a
6
+ * real driver, not a stub — `attempts` are honoured so retry behaviour stays
7
+ * testable — but three things it deliberately does NOT do:
8
+ *
9
+ * - **No delay.** `delayMs` is ignored and logged. A test asserting a
10
+ * three-day dunning gap should assert on the enqueue, not the clock.
11
+ * - **No schedules.** A cron job is registered but never fires; drive it
12
+ * directly in a test by calling the handler.
13
+ * - **No backoff waits.** Retries happen back-to-back so suites stay fast.
14
+ *
15
+ * Which is why it must never reach production: work would run inside the
16
+ * request that scheduled it, and a crash between the two would lose it. The
17
+ * host's driver factory is responsible for refusing it there.
18
+ */
19
+
20
+ import type {
21
+ AnyJobDefinition,
22
+ EnqueueOptions,
23
+ EnqueueResult,
24
+ JobContext,
25
+ JobDriver,
26
+ JobLogger,
27
+ } from "../core/types";
28
+
29
+ export interface InlineJobDriverOptions {
30
+ logger?: JobLogger;
31
+ /**
32
+ * Await the handler inside `enqueue` (the default) instead of letting it run
33
+ * detached. Awaiting is what a test wants: the assertion after `enqueue`
34
+ * sees the side effect.
35
+ */
36
+ await?: boolean;
37
+ }
38
+
39
+ /** A record of what ran, for assertions. */
40
+ export interface InlineJobRun {
41
+ name: string;
42
+ payload: unknown;
43
+ attempts: number;
44
+ error?: unknown;
45
+ }
46
+
47
+ export interface InlineJobDriver extends JobDriver {
48
+ /** Every run since the driver was created, in order. */
49
+ readonly runs: readonly InlineJobRun[];
50
+ /** Forget the recorded runs. */
51
+ clearRuns(): void;
52
+ }
53
+
54
+ export function createInlineJobDriver(
55
+ options: InlineJobDriverOptions = {},
56
+ ): InlineJobDriver {
57
+ const logger = options.logger ?? console;
58
+ const awaitHandlers = options.await ?? true;
59
+ const runs: InlineJobRun[] = [];
60
+
61
+ async function run(
62
+ definition: AnyJobDefinition,
63
+ payload: unknown,
64
+ ): Promise<void> {
65
+ const maxAttempts = Math.max(1, definition.attempts ?? 1);
66
+ const record: InlineJobRun = { name: definition.name, payload, attempts: 0 };
67
+ runs.push(record);
68
+
69
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
70
+ record.attempts = attempt;
71
+ const context: JobContext = {
72
+ runId: `inline:${definition.name}:${runs.length}:${attempt}`,
73
+ attempt,
74
+ maxAttempts,
75
+ logger,
76
+ };
77
+ try {
78
+ await definition.handle(payload as never, context);
79
+ record.error = undefined;
80
+ return;
81
+ } catch (error) {
82
+ record.error = error;
83
+ if (attempt === maxAttempts) {
84
+ logger.error(
85
+ `"${definition.name}" failed after ${attempt} attempt(s):`,
86
+ error,
87
+ );
88
+ return;
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ return {
95
+ kind: "inline",
96
+ runs,
97
+ clearRuns() {
98
+ runs.length = 0;
99
+ },
100
+ async enqueue(
101
+ definition: AnyJobDefinition,
102
+ payload: unknown,
103
+ enqueueOptions: EnqueueOptions,
104
+ ): Promise<EnqueueResult> {
105
+ if (enqueueOptions.delayMs) {
106
+ logger.warn(
107
+ `inline driver ignored a ${enqueueOptions.delayMs}ms delay on "${definition.name}".`,
108
+ );
109
+ }
110
+ const execution = run(definition, payload);
111
+ if (awaitHandlers) await execution;
112
+ else void execution;
113
+ return { enqueued: true };
114
+ },
115
+ start(definitions: readonly AnyJobDefinition[]): Promise<void> {
116
+ const scheduled = definitions.filter((job) => job.schedule);
117
+ if (scheduled.length > 0) {
118
+ logger.warn(
119
+ `inline driver does not run schedules; ${scheduled.length} cron job(s) will never fire: ${scheduled
120
+ .map((job) => job.name)
121
+ .join(", ")}`,
122
+ );
123
+ }
124
+ return Promise.resolve();
125
+ },
126
+ stop(): Promise<void> {
127
+ return Promise.resolve();
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `REDIS_URL` → the connection options BullMQ hands to ioredis.
3
+ *
4
+ * Parsed here rather than passed through as a string so the two settings a
5
+ * BullMQ deployment cannot get wrong are applied in ONE place:
6
+ *
7
+ * - `maxRetriesPerRequest: null` — required by BullMQ's blocking commands.
8
+ * ioredis' default (20) makes a worker throw on a brief Redis blip
9
+ * instead of waiting it out.
10
+ * - `enableReadyCheck: false` — the ready check trips on providers that
11
+ * restrict `INFO`, and BullMQ does not need it.
12
+ */
13
+
14
+ /** The subset of ioredis options this library sets. */
15
+ export interface RedisConnectionOptions {
16
+ host: string;
17
+ port: number;
18
+ username?: string;
19
+ password?: string;
20
+ db?: number;
21
+ tls?: Record<string, never>;
22
+ maxRetriesPerRequest: null;
23
+ enableReadyCheck: false;
24
+ }
25
+
26
+ /** Raised for a `REDIS_URL` that cannot be used. */
27
+ export class InvalidRedisUrlError extends Error {
28
+ constructor(url: string, detail: string) {
29
+ // The URL can carry a password — report the failure, never the value.
30
+ super(`REDIS_URL is not usable (${detail}). Expected redis:// or rediss://.`);
31
+ this.name = "InvalidRedisUrlError";
32
+ void url;
33
+ }
34
+ }
35
+
36
+ function toUrl(url: string): URL {
37
+ try {
38
+ return new URL(url);
39
+ } catch {
40
+ throw new InvalidRedisUrlError(url, "it is not a valid URL");
41
+ }
42
+ }
43
+
44
+ /** An empty URL component means "not given", not "the empty string". */
45
+ function optional(value: string): string | undefined {
46
+ return value === "" ? undefined : decodeURIComponent(value);
47
+ }
48
+
49
+ /** `/0`, `/1`, … selects the database; an empty path means the default. */
50
+ function parseDatabase(url: string, pathname: string): number | undefined {
51
+ const path = pathname.replace(/^\//, "");
52
+ if (path === "") return undefined;
53
+ const db = Number(path);
54
+ if (!Number.isInteger(db)) {
55
+ throw new InvalidRedisUrlError(url, "the path is not a database number");
56
+ }
57
+ return db;
58
+ }
59
+
60
+ export function parseRedisUrl(url: string): RedisConnectionOptions {
61
+ const parsed = toUrl(url);
62
+ const secure = parsed.protocol === "rediss:";
63
+ if (!secure && parsed.protocol !== "redis:") {
64
+ throw new InvalidRedisUrlError(url, `unsupported protocol "${parsed.protocol}"`);
65
+ }
66
+
67
+ return {
68
+ host: parsed.hostname || "127.0.0.1",
69
+ port: parsed.port === "" ? 6379 : Number(parsed.port),
70
+ username: optional(parsed.username),
71
+ password: optional(parsed.password),
72
+ db: parseDatabase(url, parsed.pathname),
73
+ tls: secure ? {} : undefined,
74
+ maxRetriesPerRequest: null,
75
+ enableReadyCheck: false,
76
+ };
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `@12-apps/jobs` — typed background jobs with retries, backoff and cron, behind
3
+ * a swappable driver.
4
+ *
5
+ * // where the domain lives
6
+ * export const dispatchNotification = defineJob<{ notificationId: string }>({
7
+ * name: "notifications.dispatch",
8
+ * attempts: 5,
9
+ * backoff: { type: "exponential", delayMs: 5_000 },
10
+ * handle: async ({ notificationId }) => dispatchDeliveries(notificationId),
11
+ * });
12
+ *
13
+ * // at an emit site
14
+ * await dispatchNotification.enqueue({ notificationId }, { dedupeKey: notificationId });
15
+ *
16
+ * // at process start
17
+ * configureJobs({ driver: createBullMqJobDriver({ redisUrl, logger }), logger });
18
+ * await startJobWorkers(); // consumers only
19
+ *
20
+ * The two rules that keep this safe are documented on `JobDefinition`:
21
+ * payloads carry identifiers rather than state, and every handler is
22
+ * idempotent because delivery is at-least-once.
23
+ *
24
+ * The BullMQ driver is deliberately NOT re-exported here — it is imported
25
+ * from `@12-apps/jobs/bullmq`, so that pulling in `defineJob` at an emit site
26
+ * never drags Redis into a bundle that only ever enqueues.
27
+ */
28
+
29
+ export { defineJob, findJob, listJobs, clearJobs, DuplicateJobError } from "./core/registry";
30
+ export type { RegisteredJob } from "./core/registry";
31
+
32
+ export {
33
+ configureJobs,
34
+ enqueueJob,
35
+ getJobDriver,
36
+ getJobLogger,
37
+ resetJobRuntime,
38
+ startJobWorkers,
39
+ stopJobs,
40
+ } from "./core/runtime";
41
+
42
+ export type {
43
+ AnyJobDefinition,
44
+ EnqueueOptions,
45
+ EnqueueResult,
46
+ EnqueueSkipReason,
47
+ JobBackoff,
48
+ JobContext,
49
+ JobDefinition,
50
+ JobDriver,
51
+ JobHandler,
52
+ JobLogger,
53
+ JobSchedule,
54
+ } from "./core/types";
55
+
56
+ export { createInlineJobDriver } from "./drivers/inline";
57
+ export type { InlineJobDriver, InlineJobRun } from "./drivers/inline";
58
+
59
+ export { parseRedisUrl, InvalidRedisUrlError } from "./drivers/redis-url";
60
+ export type { RedisConnectionOptions } from "./drivers/redis-url";