@12-apps/jobs 1.19.0 → 1.20.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.
@@ -0,0 +1,363 @@
1
+ import { listJobs } from "../core/registry";
2
+ import {
3
+ configureJobs,
4
+ getJobDriver,
5
+ startJobWorkers,
6
+ stopJobs,
7
+ } from "../core/runtime";
8
+ import type { JobLogger } from "../core/types";
9
+ import {
10
+ createSweepLease,
11
+ type SweepLeaseDbProvider,
12
+ type WithSweepLease,
13
+ } from "../lease/sweep-lease";
14
+
15
+ import {
16
+ isWorkerProcess,
17
+ resolveConfig,
18
+ type JobsServerConfig,
19
+ type JobsSource,
20
+ type ResolvedConfig,
21
+ } from "./config";
22
+ import { resolveDriver } from "./resolve-driver";
23
+
24
+ /**
25
+ * `createApiJobs` — the jobs runtime as ONE mountable surface.
26
+ *
27
+ * The bootstrap used to live in the host: a runtime module that read the
28
+ * environment, picked a driver, refused the unsafe one in production,
29
+ * registered every job, started the workers and hooked the drain signals.
30
+ * None of that is host domain — the only decisions a host genuinely owns are
31
+ * WHICH jobs exist and WHERE the lease table lives, and both arrive here as
32
+ * config.
33
+ *
34
+ * ## Two roles, one image
35
+ *
36
+ * A PRODUCER (the web server) configures the driver and enqueues. A CONSUMER
37
+ * (the worker) does that and also starts consuming, which is the only
38
+ * difference between the two — so the worker is the SAME container image with
39
+ * `JOBS_WORKER=1` set, not a second build. Splitting the roles keeps a slow
40
+ * job off the request path; running them in one process (set `JOBS_WORKER=1`
41
+ * on the web service and skip the worker service) also works and is a
42
+ * reasonable choice on a single small box.
43
+ *
44
+ * ## Fail closed, never fail loud
45
+ *
46
+ * A misconfigured queue must never take the host app down. Every problem here
47
+ * (no `REDIS_URL` in production, `inline` requested in production, an
48
+ * unparseable URL, `bullmq` failing to load) resolves to NO driver plus a
49
+ * loud error: enqueues then report `no-driver` instead of throwing, the
50
+ * durable rows still get written, and the sweeps catch up once a worker
51
+ * exists. The health endpoint reports the same fact as a 503, so a probe can
52
+ * see what the log line said.
53
+ *
54
+ * ## Zero config is a real mode
55
+ *
56
+ * `createApiJobs({ jobs })` with nothing else and no `REDIS_URL` in the
57
+ * environment resolves to the INLINE driver outside production: handlers run
58
+ * in-process, schedules do not fire (and say so), and the app starts green
59
+ * with no Redis container. That is the default a fresh host is supposed to
60
+ * boot in.
61
+ *
62
+ * ## The environment is read at `start()`, not at the factory
63
+ *
64
+ * future-pay's `bootstrapJobs()` read the whole matrix — `JOBS_DRIVER`,
65
+ * `REDIS_URL`, `NODE_ENV`, `JOBS_WORKER`, `JOBS_QUEUE_PREFIX` — at the moment
66
+ * it ran, and the recommended host shape is factory-at-module-scope +
67
+ * `await jobsApi.start()` at process start. Reading part of the matrix at
68
+ * factory time would honour a late-arriving `JOBS_DRIVER` and silently
69
+ * ignore a late-arriving `REDIS_URL` (a host whose config module loads after
70
+ * the module that built the api), so the factory reads nothing and `start()`
71
+ * resolves everything at one single moment.
72
+ */
73
+
74
+ /** What a route handler answers; the host maps it onto its response type. */
75
+ export interface JobsResponse {
76
+ status: number;
77
+ body: unknown;
78
+ }
79
+
80
+ /** A framework-neutral route descriptor, the same shape report-builder mounts. */
81
+ export interface JobsRoute {
82
+ method: "GET";
83
+ /** Path relative to the host's jobs mount, e.g. `/health`. */
84
+ path: string;
85
+ handle(): Promise<JobsResponse>;
86
+ }
87
+
88
+ /**
89
+ * The health payload `GET /health` answers with.
90
+ *
91
+ * Three states, two status codes: `ok` (200) is a working runtime; `disabled`
92
+ * (200) is an EXPLICIT `off` — `JOBS_DRIVER=off` or
93
+ * `createApiJobs({ driver: "off" })` — OUTSIDE production: a review box or CI
94
+ * that genuinely wants no queue, which must not fail a readiness aggregate
95
+ * forever; `degraded` (503) is everything that is wrong rather than chosen —
96
+ * a misconfiguration, a failed start, a drained worker, and any production
97
+ * with no queue, spelled out or not (production never deliberately wants
98
+ * none).
99
+ */
100
+ export interface JobsHealth {
101
+ status: "ok" | "degraded" | "disabled";
102
+ checks: {
103
+ /**
104
+ * `start()` has completed WITHOUT THROWING in this process. A start that
105
+ * rejected (a duplicate job name, a failing registration import) leaves
106
+ * this false and the status degraded — a failed boot must never probe
107
+ * green.
108
+ */
109
+ configured: boolean;
110
+ /**
111
+ * The resolved driver's kind, or null when jobs are disabled. Reads the
112
+ * process-wide runtime: with two `createApiJobs` instances in one process
113
+ * (never a real host's shape) each reports whichever driver was
114
+ * configured last.
115
+ */
116
+ driver: string | null;
117
+ /**
118
+ * Whether this process is meant to consume, not just enqueue.
119
+ * Provisional until `start()` has run: before then it is a best-effort
120
+ * read of `config.worker` / `JOBS_WORKER` at probe time (pre-start health
121
+ * is degraded regardless, so nothing is decided from it).
122
+ */
123
+ worker: boolean;
124
+ /** Whether consuming actually began (always false in a producer). */
125
+ consuming: boolean;
126
+ /** Registered job definitions. */
127
+ jobs: number;
128
+ /** How many of them run on a cron schedule. */
129
+ schedules: number;
130
+ };
131
+ }
132
+
133
+ export interface JobsApi {
134
+ /** The health endpoint. Mount with `@12-apps/jobs/hono` or your own adapter. */
135
+ routes: JobsRoute[];
136
+ /**
137
+ * Configure the driver, register every job, and — in a worker — start
138
+ * consuming and install the cron schedules. Call it once at process start,
139
+ * before the first request; safe to call again (no-op). Never throws for a
140
+ * MISCONFIGURED queue (see "fail closed" above) — only for a programming
141
+ * error such as a duplicate job name.
142
+ */
143
+ start(): Promise<void>;
144
+ /** Stop consuming and release the driver's connections, draining in-flight jobs. */
145
+ stop(): Promise<void>;
146
+ /**
147
+ * Single-writer lease for cron sweeps, bound to `config.db`. See
148
+ * {@link createSweepLease} for the contract.
149
+ */
150
+ withSweepLease: WithSweepLease;
151
+ }
152
+
153
+ /** Trigger the host's registrations. An array means "already registered". */
154
+ async function registerJobs(jobs: JobsSource): Promise<void> {
155
+ if (typeof jobs === "function") await jobs();
156
+ }
157
+
158
+ /** The lease bound to the configured db — or, without one, a loud refusal. */
159
+ function bindSweepLease(db: SweepLeaseDbProvider | undefined): WithSweepLease {
160
+ const lease = db ? createSweepLease({ db }) : null;
161
+ // Async so the refusal is a REJECTION, which is what the type promises: a
162
+ // synchronous throw from a Promise-returning function escapes `.catch()`
163
+ // and `Promise.all`, and a caller that collected the promise before
164
+ // awaiting it would never see the error at all.
165
+ return async (name, ttlMs, work) => {
166
+ if (!lease) {
167
+ // Loud, not silent: a sweep whose lease quietly no-ops is exactly the
168
+ // unprotected concurrent pass the lease exists to prevent. Same rule as
169
+ // the claim itself — only a lost race may be silent.
170
+ throw new Error(
171
+ "withSweepLease needs a `db` provider in createApiJobs({ db }).",
172
+ );
173
+ }
174
+ return lease.withSweepLease(name, ttlMs, work);
175
+ };
176
+ }
177
+
178
+ /** What one `start()`/`stop()` cycle has actually done in this process. */
179
+ interface RuntimeState {
180
+ /**
181
+ * `start()` has been ENTERED — the idempotency latch, exactly future-pay's
182
+ * `bootstrapped` flag: a second call is a no-op, and a start that threw is
183
+ * not retried by calling it again.
184
+ */
185
+ starting: boolean;
186
+ /**
187
+ * `start()` has COMPLETED without throwing — the success flag health keys
188
+ * on. Kept separate from `starting` because a start that rejected must
189
+ * leave the probe red, not report the half-configured runtime as ok.
190
+ */
191
+ started: boolean;
192
+ consuming: boolean;
193
+ /** Resolution chose "no driver" because it was TOLD to — see {@link JobsHealth}. */
194
+ deliberatelyOff: boolean;
195
+ /**
196
+ * The drain hooks have been installed — once per instance, ever. A
197
+ * stop()/start() cycle must not stack a second `process.once` pair, or one
198
+ * real signal would drain the driver twice.
199
+ */
200
+ hooksInstalled: boolean;
201
+ /**
202
+ * The env-resolved config, cached by `start()`. Null until then: the
203
+ * factory deliberately reads no environment (see the module header), so a
204
+ * pre-start health probe falls back to reading the worker switch at handle
205
+ * time.
206
+ */
207
+ resolved: ResolvedConfig | null;
208
+ }
209
+
210
+ /** "disabled" is a choice, "ok" is a working runtime, "degraded" is a fault. */
211
+ function statusOf(
212
+ state: RuntimeState,
213
+ driverKind: string | null,
214
+ worker: boolean,
215
+ ): JobsHealth["status"] {
216
+ if (state.started && state.deliberatelyOff) return "disabled";
217
+ const ready = state.started && driverKind !== null && (!worker || state.consuming);
218
+ return ready ? "ok" : "degraded";
219
+ }
220
+
221
+ function healthOf(config: JobsServerConfig, state: RuntimeState): JobsHealth {
222
+ const definitions = listJobs();
223
+ const driver = getJobDriver()?.kind ?? null;
224
+ const worker = state.resolved
225
+ ? state.resolved.worker
226
+ : (config.worker ?? isWorkerProcess());
227
+ return {
228
+ status: statusOf(state, driver, worker),
229
+ checks: {
230
+ configured: state.started,
231
+ driver,
232
+ worker,
233
+ consuming: state.consuming,
234
+ jobs: definitions.length,
235
+ schedules: definitions.filter((job) => job.schedule).length,
236
+ },
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Let in-flight jobs finish on a deploy. Deliberately does NOT call
242
+ * process.exit — the host owns the shutdown, and cutting it short here would
243
+ * kill the very requests we are draining for.
244
+ *
245
+ * The hook clears the instance state BEFORE the async drain: a drained worker
246
+ * is no longer consuming, and the readiness probe must stop saying it is the
247
+ * moment the drain begins — that flip is what tells a rolling deploy to stop
248
+ * routing to this process.
249
+ */
250
+ function installDrainHooks(logger: JobLogger, state: RuntimeState): void {
251
+ for (const signal of ["SIGTERM", "SIGINT"] as const) {
252
+ process.once(signal, () => {
253
+ state.consuming = false;
254
+ state.started = false;
255
+ void stopJobs().catch((error) => logger.error("shutdown failed:", error));
256
+ });
257
+ }
258
+ }
259
+
260
+ /**
261
+ * The `start()` body: configure, register, and — in a worker — consume.
262
+ *
263
+ * `state.started` is set LAST on every successful path, so a throw anywhere
264
+ * in here (a duplicate job name, a registration import that fails) leaves the
265
+ * health endpoint degraded rather than reporting the half-configured runtime
266
+ * as ok.
267
+ */
268
+ async function startRuntime(
269
+ config: JobsServerConfig,
270
+ resolved: ResolvedConfig,
271
+ state: RuntimeState,
272
+ ): Promise<void> {
273
+ const { logger, worker } = resolved;
274
+ // A stop() that lands while start() is awaiting wins: `stop()` clears
275
+ // `starting`, and each publish point below re-checks it — otherwise the
276
+ // start's continuation would set consuming/started AFTER the stop and a
277
+ // stopped runtime would advertise itself as a healthy consumer (the same
278
+ // untruth the drain-hook fix closed, one window narrower).
279
+ const stopped = (): boolean => !state.starting;
280
+
281
+ const { driver, deliberatelyOff } = await resolveDriver(config, resolved);
282
+ if (stopped()) return;
283
+ state.deliberatelyOff = deliberatelyOff;
284
+ if (!driver) {
285
+ // Fail closed, by design: "no driver" is a completed start (future-pay's
286
+ // bootstrapJobs returned normally here too). Health still reports it —
287
+ // the driver check is null — so the probe sees what the log line said.
288
+ state.started = true;
289
+ return;
290
+ }
291
+
292
+ configureJobs({ driver, logger });
293
+
294
+ // Registers every job. Must happen BEFORE `startJobWorkers`, which consumes
295
+ // whatever is registered — a worker cannot consume a job it has never heard
296
+ // of, and a producer cannot enqueue one.
297
+ await registerJobs(config.jobs);
298
+ if (stopped()) return;
299
+
300
+ if (!worker) {
301
+ logger.info("producer mode: jobs will be enqueued, not consumed by this process.");
302
+ state.started = true;
303
+ return;
304
+ }
305
+
306
+ await startJobWorkers();
307
+ if (stopped()) return;
308
+ state.consuming = true;
309
+
310
+ if ((config.installShutdownHooks ?? true) && !state.hooksInstalled) {
311
+ installDrainHooks(logger, state);
312
+ state.hooksInstalled = true;
313
+ }
314
+ state.started = true;
315
+ }
316
+
317
+ export function createApiJobs(config: JobsServerConfig): JobsApi {
318
+ const state: RuntimeState = {
319
+ starting: false,
320
+ started: false,
321
+ consuming: false,
322
+ deliberatelyOff: false,
323
+ hooksInstalled: false,
324
+ resolved: null,
325
+ };
326
+
327
+ return {
328
+ routes: [
329
+ {
330
+ method: "GET",
331
+ path: "/health",
332
+ handle: async () => {
333
+ const body = healthOf(config, state);
334
+ // Only "degraded" is a failure; "disabled" is a choice and must not
335
+ // fail a readiness aggregate forever.
336
+ return { status: body.status === "degraded" ? 503 : 200, body };
337
+ },
338
+ },
339
+ ],
340
+
341
+ async start(): Promise<void> {
342
+ // The latch, not the success flag: a second call is a no-op, and a
343
+ // start that threw is not silently retried — future-pay's bootstrapJobs
344
+ // latched `bootstrapped` the same way. Success is `startRuntime`'s to
345
+ // declare, as its last statement.
346
+ if (state.starting) return;
347
+ state.starting = true;
348
+ // The whole env matrix is read HERE, at the same moment the driver
349
+ // choice reads JOBS_DRIVER — never at factory time.
350
+ state.resolved = resolveConfig(config);
351
+ await startRuntime(config, state.resolved, state);
352
+ },
353
+
354
+ async stop(): Promise<void> {
355
+ await stopJobs();
356
+ state.consuming = false;
357
+ state.started = false;
358
+ state.starting = false;
359
+ },
360
+
361
+ withSweepLease: bindSweepLease(config.db),
362
+ };
363
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `@12-apps/jobs/server` — the backend factory.
3
+ *
4
+ * One call wires the whole operational half: driver resolution (with the
5
+ * inline zero-config default), job registration, the worker switch, graceful
6
+ * drain, the sweep lease and a health endpoint the host mounts wherever its
7
+ * internal probes live.
8
+ *
9
+ * const jobsApi = createApiJobs({
10
+ * jobs: () => import("./lib/jobs"), // defineJob modules
11
+ * db: () => getPrismaClient(), // the sweep_leases table
12
+ * });
13
+ * await jobsApi.start(); // at process start
14
+ * app.route("/api/internal/jobs", jobsRouter(jobsApi)); // ./hono
15
+ *
16
+ * There is deliberately NO `createWebJobs` half: this runtime has no screens
17
+ * — its API surface is the health endpoint, and its user-visible effects are
18
+ * whatever the host's job handlers do. See ADOPTING.md.
19
+ */
20
+ export { createApiJobs } from "./create-api-jobs";
21
+ export type {
22
+ JobsApi,
23
+ JobsHealth,
24
+ JobsResponse,
25
+ JobsRoute,
26
+ } from "./create-api-jobs";
27
+ export type { JobsDriverChoice, JobsServerConfig, JobsSource } from "./config";
@@ -0,0 +1,202 @@
1
+ import type { JobDriver } from "../core/types";
2
+ import { createInlineJobDriver } from "../drivers/inline";
3
+
4
+ import type { JobsDriverChoice, JobsServerConfig, ResolvedConfig } from "./config";
5
+
6
+ /**
7
+ * Driver resolution — future-pay `runtime.ts`'s `readDriverChoice` +
8
+ * `resolveDriver`. The choice can also arrive through `config.driver` (a name
9
+ * or an instance), which future-pay had no seam for — so the resolution
10
+ * tracks WHERE the choice came from, validates the config path exactly as
11
+ * strictly as the env path, and names the right knob in every error message.
12
+ * The `JOBS_DRIVER=…` messages stay byte-identical to future-pay's, because
13
+ * a host greps for them.
14
+ */
15
+
16
+ /** A resolved choice, and which knob said so. */
17
+ interface DriverChoice {
18
+ choice: JobsDriverChoice;
19
+ /**
20
+ * `env` is `JOBS_DRIVER`; `config` is `createApiJobs({ driver })`;
21
+ * `default` is the matrix (Redis presence / production). An INVALID value
22
+ * resolves to `off` with source `default`: a misconfiguration is not a
23
+ * deliberate opt-out.
24
+ */
25
+ source: "config" | "env" | "default";
26
+ }
27
+
28
+ /** The allow-list. Anything else is a misconfiguration, never a guess. */
29
+ function asChoice(value: string): JobsDriverChoice | null {
30
+ return value === "bullmq" || value === "inline" || value === "off" ? value : null;
31
+ }
32
+
33
+ function resolveChoice(
34
+ config: JobsServerConfig,
35
+ resolved: ResolvedConfig,
36
+ ): DriverChoice {
37
+ if (typeof config.driver === "string") {
38
+ const named = asChoice(config.driver.trim().toLowerCase());
39
+ if (named) return { choice: named, source: "config" };
40
+ // The type forbids this, but a JS consumer can pass anything — and the
41
+ // env path validates, so the config path must too: same value, same
42
+ // loud-and-off outcome, never a silent fallthrough to bullmq.
43
+ resolved.logger.error(
44
+ `createApiJobs({ driver: "${config.driver}" }) is not a driver; jobs are disabled.`,
45
+ );
46
+ return { choice: "off", source: "default" };
47
+ }
48
+ if (config.driver !== undefined) {
49
+ // Not a string, and the caller already peeled off object instances — so
50
+ // this is 42, true, null: junk only a JS consumer can pass. It must not
51
+ // fall through to the env matrix as if nothing had been said.
52
+ resolved.logger.error(
53
+ `createApiJobs({ driver: ${JSON.stringify(config.driver)} }) is not a driver; jobs are disabled.`,
54
+ );
55
+ return { choice: "off", source: "default" };
56
+ }
57
+ const configured = process.env.JOBS_DRIVER?.trim().toLowerCase();
58
+ if (configured) {
59
+ const named = asChoice(configured);
60
+ if (named) return { choice: named, source: "env" };
61
+ resolved.logger.error(
62
+ `JOBS_DRIVER="${configured}" is not a driver; jobs are disabled.`,
63
+ );
64
+ return { choice: "off", source: "default" };
65
+ }
66
+ // Unset: Redis being configured is the signal that a queue exists.
67
+ if (resolved.redisUrl) return { choice: "bullmq", source: "default" };
68
+ return { choice: resolved.production ? "off" : "inline", source: "default" };
69
+ }
70
+
71
+ /**
72
+ * Names the knob a choice came from, so an operator debugging the log line is
73
+ * sent to the setting that actually said it — a `JOBS_DRIVER` message for a
74
+ * choice made in code would have them hunting for an env var that is not set.
75
+ */
76
+ function knob(choice: JobsDriverChoice, source: DriverChoice["source"]): string {
77
+ return source === "config"
78
+ ? `createApiJobs({ driver: "${choice}" })`
79
+ : `JOBS_DRIVER=${choice}`;
80
+ }
81
+
82
+ /**
83
+ * What resolution decided, and whether "no driver" was a deliberate choice.
84
+ * Not exported: `createApiJobs` destructures it, and an exported type nothing
85
+ * imports is exactly what the knip gate is there to stop.
86
+ */
87
+ interface DriverResolution {
88
+ driver: JobDriver | null;
89
+ /**
90
+ * True only for an EXPLICIT `off` — `JOBS_DRIVER=off` or
91
+ * `createApiJobs({ driver: "off" })` — OUTSIDE production: a review box or
92
+ * a CI environment that genuinely wants no queue. False for every
93
+ * misconfiguration that merely RESOLVES to off, and false in production
94
+ * even when spelled out (production never deliberately wants no queue), so
95
+ * health can tell "disabled by choice" apart from "broken".
96
+ */
97
+ deliberatelyOff: boolean;
98
+ }
99
+
100
+ const off = (deliberatelyOff: boolean): DriverResolution => ({
101
+ driver: null,
102
+ deliberatelyOff,
103
+ });
104
+
105
+ /**
106
+ * An instance passed in is the host's own decision — for anything except the
107
+ * inline driver in production, which is refused for exactly the reason the
108
+ * NAMED choice is: no retries, no schedules, and work that dies with the
109
+ * request that enqueued it. An instance must not be the quiet way around
110
+ * that guard.
111
+ */
112
+ function resolveInstance(driver: JobDriver, resolved: ResolvedConfig): DriverResolution {
113
+ if (resolved.production && driver.kind === "inline") {
114
+ resolved.logger.error(
115
+ "an inline driver instance is refused in production; jobs are disabled.",
116
+ );
117
+ return off(false);
118
+ }
119
+ return { driver, deliberatelyOff: false };
120
+ }
121
+
122
+ /** The bullmq branch: a Redis URL is required, and the import stays lazy. */
123
+ async function resolveBullMq(
124
+ choice: DriverChoice,
125
+ resolved: ResolvedConfig,
126
+ ): Promise<DriverResolution> {
127
+ const { logger, redisUrl } = resolved;
128
+ if (!redisUrl) {
129
+ logger.error(`${knob(choice.choice, choice.source)} needs REDIS_URL; jobs are disabled.`);
130
+ return off(false);
131
+ }
132
+ try {
133
+ // Imported lazily so `bullmq` (and ioredis) are pulled in only by a
134
+ // process that actually talks to Redis — never into a bundle that only
135
+ // enqueues. The specifier is a literal, which is what keeps it loadable
136
+ // once the host bundles this package's published TS source.
137
+ const { createBullMqJobDriver } = await import("../drivers/bullmq");
138
+ return {
139
+ driver: createBullMqJobDriver({ redisUrl, logger, prefix: resolved.queuePrefix }),
140
+ deliberatelyOff: false,
141
+ };
142
+ } catch (error) {
143
+ logger.error("could not create the BullMQ driver; jobs are disabled:", error);
144
+ return off(false);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Pick the driver, or NULL for "jobs are disabled in this process" — which is
150
+ * always accompanied by a loud log line, never silent, unless it was a
151
+ * deliberate `off`.
152
+ */
153
+ export async function resolveDriver(
154
+ config: JobsServerConfig,
155
+ resolved: ResolvedConfig,
156
+ ): Promise<DriverResolution> {
157
+ const { logger, production } = resolved;
158
+
159
+ if (config.driver && typeof config.driver === "object") {
160
+ return resolveInstance(config.driver, resolved);
161
+ }
162
+
163
+ const named = resolveChoice(config, resolved);
164
+ const { choice, source } = named;
165
+
166
+ if (choice === "off") {
167
+ if (production) {
168
+ // Logged in production even for an explicit off — the BEHAVIOUR is
169
+ // future-pay's runtime.ts, though not the bytes: its message named the
170
+ // domain sweeps, and the examples were dropped when this moved into
171
+ // the package. Production with no queue is worth a line in the log
172
+ // however it came about.
173
+ logger.error(
174
+ "No job driver: scheduled work will NOT run in this deployment. Set REDIS_URL.",
175
+ );
176
+ }
177
+ // Deliberate only OUTSIDE production. A review box or CI genuinely wants
178
+ // no queue; production never does — a JOBS_DRIVER=off that reaches it
179
+ // through a shared env template or a promoted staging config is a
180
+ // mistake, and the probe must answer 503 exactly like the unconfigured
181
+ // case rather than green-light the same fact the error line above just
182
+ // reported.
183
+ return off(source !== "default" && !production);
184
+ }
185
+
186
+ if (choice === "inline") {
187
+ if (production) {
188
+ // Inline runs the handler inside whatever request enqueued it and has
189
+ // no retries and no schedules. That is a development convenience, and
190
+ // in production it would silently drop work a crash interrupts.
191
+ logger.error(`${knob(choice, source)} is refused in production; jobs are disabled.`);
192
+ return off(false);
193
+ }
194
+ logger.info("using the inline job driver (no Redis): schedules will not fire.");
195
+ // Detached on purpose: in a dev server the handler must not run inside
196
+ // the request that enqueued it. A test that wants to await the handler
197
+ // passes its own `createInlineJobDriver({ await: true })` instance.
198
+ return { driver: createInlineJobDriver({ logger, await: false }), deliberatelyOff: false };
199
+ }
200
+
201
+ return resolveBullMq(named, resolved);
202
+ }