@12-apps/jobs 2.0.0 → 4.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.
@@ -1,18 +1,22 @@
1
1
  import type { RegisteredJob } from "../core/registry";
2
- import type { JobDriver, JobLogger } from "../core/types";
2
+ import type {
3
+ JobDriver,
4
+ JobEvents,
5
+ JobLogger,
6
+ JobRetention,
7
+ } from "../core/types";
3
8
  import type { SweepLeaseDbProvider } from "../lease/sweep-lease";
4
9
 
5
10
  /**
6
11
  * The factory's config surface, and how it resolves against the environment.
7
12
  *
8
13
  * Every environment read happens in {@link resolveConfig}, which `start()`
9
- * calls — NOT the factory. future-pay's `bootstrapJobs()` read the whole
10
- * matrix (`JOBS_DRIVER`, `REDIS_URL`, `NODE_ENV`, `JOBS_WORKER`,
11
- * `JOBS_QUEUE_PREFIX`) at the moment it ran, and the recommended host shape
12
- * is factory-at-module-scope + `await jobsApi.start()` later — so an env var
13
- * that arrives between the two (a config module loading after the route
14
- * module) must be honoured for ALL of the matrix, not silently for one
15
- * variable and not the others.
14
+ * calls — NOT the factory. The whole matrix (`JOBS_DRIVER`, `REDIS_URL`,
15
+ * `NODE_ENV`, `JOBS_WORKER`, `JOBS_QUEUE_PREFIX`) has to be read at ONE
16
+ * moment, because the recommended host shape is factory-at-module-scope +
17
+ * `await jobsApi.start()` later: an env var that arrives between the two (a
18
+ * config module loading after the module that built the api) must be honoured
19
+ * for all of the matrix, not silently for one variable and not the others.
16
20
  */
17
21
 
18
22
  /** What the driver choice may say (`JOBS_DRIVER`, or `config.driver`). */
@@ -21,16 +25,38 @@ export type JobsDriverChoice = "bullmq" | "inline" | "off";
21
25
  /**
22
26
  * How the host names its jobs. An import thunk (`() => import("./jobs")`) is
23
27
  * the usual form — `defineJob` registers at module scope, so importing the
24
- * modules IS the registration. An array of already-registered jobs is
25
- * accepted for hosts (and tests) that hold the references anyway; it forces
26
- * the modules to have been imported, which is the same guarantee.
28
+ * modules IS the registration. An array of already-registered jobs is accepted
29
+ * for hosts (and tests) that hold the references anyway; it forces the modules
30
+ * to have been imported, which is the same guarantee.
31
+ *
32
+ * Neither form may be EMPTY. See {@link JobsConfigError}.
27
33
  */
28
34
  export type JobsSource =
29
35
  | readonly RegisteredJob<never>[]
30
36
  | (() => unknown | Promise<unknown>);
31
37
 
38
+ /**
39
+ * Raised at ASSEMBLY for a config that cannot work — today, a `jobs` that
40
+ * names nothing.
41
+ *
42
+ * A required option that is never checked is still fail-open. `jobs: []` type-
43
+ * checks, starts, resolves a driver, installs no schedule, consumes no queue
44
+ * and answers `/health` with `status: "ok"` — every scheduled job in the
45
+ * deployment silently stops and the probe stays green. Refusing at the factory
46
+ * puts the failure at the line that wrote it.
47
+ */
48
+ export class JobsConfigError extends Error {
49
+ constructor(detail: string) {
50
+ super(`createApiJobs: ${detail}`);
51
+ this.name = "JobsConfigError";
52
+ }
53
+ }
54
+
32
55
  export interface JobsServerConfig {
33
- /** Every job this process can enqueue or consume. See {@link JobsSource}. */
56
+ /**
57
+ * Every job this process can enqueue or consume. See {@link JobsSource}.
58
+ * Required, and refused when it names nothing.
59
+ */
34
60
  jobs: JobsSource;
35
61
  /**
36
62
  * A driver INSTANCE (tests, exotic hosts), a choice by name, or unset to
@@ -58,6 +84,22 @@ export interface JobsServerConfig {
58
84
  queuePrefix?: string;
59
85
  /** The host's logger. Defaults to the console. */
60
86
  logger?: JobLogger;
87
+ /**
88
+ * Where completions, dead-letters and removed schedules are reported. This
89
+ * package never notifies, audits or publishes anything itself — it exports
90
+ * the moment and the host wires the consequence.
91
+ */
92
+ events?: JobEvents;
93
+ /**
94
+ * How long finished jobs are kept. Defaults to the package's bounded
95
+ * default (a day of successes, a week of failures).
96
+ */
97
+ retention?: JobRetention;
98
+ /**
99
+ * Per-queue worker concurrency when no job on the queue states one. A job
100
+ * that states `concurrency: 1` still gets 1 — a stated value always wins.
101
+ */
102
+ defaultConcurrency?: number;
61
103
  /**
62
104
  * Where the `sweep_leases` table lives — enables `withSweepLease` on the
63
105
  * factory's return. Omit it and the lease helper rejects on first use,
@@ -80,6 +122,29 @@ const consoleLogger: JobLogger = {
80
122
  error: (message, ...meta) => console.error(`[jobs] ${message}`, ...meta),
81
123
  };
82
124
 
125
+ /**
126
+ * Refuse a `jobs` that names nothing, at assembly.
127
+ *
128
+ * The array form is decidable here and is refused here. The thunk form is not
129
+ * — running it is the only way to know what it registers — so it is checked
130
+ * again after registration, inside `start()`. Both paths end at the same
131
+ * refusal, which is why neither is optional.
132
+ */
133
+ export function assertJobsDeclared(jobs: JobsSource | undefined): void {
134
+ if (typeof jobs === "function") return;
135
+ if (!Array.isArray(jobs)) {
136
+ throw new JobsConfigError(
137
+ "`jobs` is required — an import thunk (() => import('./jobs')) or a non-empty array of defineJob() results.",
138
+ );
139
+ }
140
+ if (jobs.length === 0) {
141
+ throw new JobsConfigError(
142
+ "`jobs: []` declares no work. A process with no jobs consumes no queue, installs no " +
143
+ "schedule and still reports itself healthy — pass the jobs, or do not mount this package.",
144
+ );
145
+ }
146
+ }
147
+
83
148
  /** `JOBS_WORKER=1|true` is how a deployment marks the consuming process. */
84
149
  export function isWorkerProcess(): boolean {
85
150
  const flag = process.env.JOBS_WORKER?.trim().toLowerCase();
@@ -93,6 +158,9 @@ export interface ResolvedConfig {
93
158
  worker: boolean;
94
159
  queuePrefix: string | undefined;
95
160
  logger: JobLogger;
161
+ events: JobEvents | undefined;
162
+ retention: JobRetention | undefined;
163
+ defaultConcurrency: number | undefined;
96
164
  }
97
165
 
98
166
  /**
@@ -108,5 +176,8 @@ export function resolveConfig(config: JobsServerConfig): ResolvedConfig {
108
176
  worker: config.worker ?? isWorkerProcess(),
109
177
  queuePrefix: config.queuePrefix ?? process.env.JOBS_QUEUE_PREFIX,
110
178
  logger: config.logger ?? consoleLogger,
179
+ events: config.events,
180
+ retention: config.retention,
181
+ defaultConcurrency: config.defaultConcurrency,
111
182
  };
112
183
  }
@@ -1,4 +1,5 @@
1
- import { listJobs } from "../core/registry";
1
+ import { assertJobsRegistered, listJobs } from "../core/registry";
2
+ import { assertValidRetention } from "../core/retention";
2
3
  import {
3
4
  configureJobs,
4
5
  getJobDriver,
@@ -13,6 +14,7 @@ import {
13
14
  } from "../lease/sweep-lease";
14
15
 
15
16
  import {
17
+ assertJobsDeclared,
16
18
  isWorkerProcess,
17
19
  resolveConfig,
18
20
  type JobsServerConfig,
@@ -59,16 +61,23 @@ import { resolveDriver } from "./resolve-driver";
59
61
  * with no Redis container. That is the default a fresh host is supposed to
60
62
  * boot in.
61
63
  *
64
+ * ## `jobs` is refused when it names nothing
65
+ *
66
+ * "Fail closed, never fail loud" is about the QUEUE. It does not extend to the
67
+ * wiring: `jobs: []`, or a thunk that registers nothing because its import was
68
+ * dropped, produces a process that resolves a driver, consumes no queue,
69
+ * installs no schedule and answers `/health` with `ok`. Every scheduled job in
70
+ * the deployment stops and nothing reports it. So an empty `jobs` throws — at
71
+ * the factory for the array form, and after registration for the thunk form.
72
+ *
62
73
  * ## The environment is read at `start()`, not at the factory
63
74
  *
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.
75
+ * The recommended host shape is factory-at-module-scope + `await
76
+ * jobsApi.start()` at process start. Reading part of the matrix at factory
77
+ * time would honour a late-arriving `JOBS_DRIVER` and silently ignore a
78
+ * late-arriving `REDIS_URL` (a host whose config module loads after the module
79
+ * that built the api), so the factory reads nothing and `start()` resolves
80
+ * everything at one single moment.
72
81
  */
73
82
 
74
83
  /** What a route handler answers; the host maps it onto its response type. */
@@ -77,7 +86,7 @@ export interface JobsResponse {
77
86
  body: unknown;
78
87
  }
79
88
 
80
- /** A framework-neutral route descriptor, the same shape report-builder mounts. */
89
+ /** A framework-neutral route descriptor. */
81
90
  export interface JobsRoute {
82
91
  method: "GET";
83
92
  /** Path relative to the host's jobs mount, e.g. `/health`. */
@@ -178,9 +187,8 @@ function bindSweepLease(db: SweepLeaseDbProvider | undefined): WithSweepLease {
178
187
  /** What one `start()`/`stop()` cycle has actually done in this process. */
179
188
  interface RuntimeState {
180
189
  /**
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.
190
+ * `start()` has been ENTERED — the idempotency latch: a second call is a
191
+ * no-op, and a start that threw is not retried by calling it again.
184
192
  */
185
193
  starting: boolean;
186
194
  /**
@@ -282,9 +290,11 @@ async function startRuntime(
282
290
  if (stopped()) return;
283
291
  state.deliberatelyOff = deliberatelyOff;
284
292
  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.
293
+ // Fail closed, by design: "no driver" is a completed start. Health still
294
+ // reports it — the driver check is null — so the probe sees what the log
295
+ // line said. Registration is deliberately skipped: a process with jobs
296
+ // disabled must not install seams whose enqueues would all report
297
+ // `no-driver`.
288
298
  state.started = true;
289
299
  return;
290
300
  }
@@ -295,6 +305,11 @@ async function startRuntime(
295
305
  // whatever is registered — a worker cannot consume a job it has never heard
296
306
  // of, and a producer cannot enqueue one.
297
307
  await registerJobs(config.jobs);
308
+ // The thunk form's half of the empty-`jobs` refusal (the array form was
309
+ // refused at the factory). It runs for BOTH roles: a producer that
310
+ // registered nothing enqueues nothing, which is the same silent stop as a
311
+ // worker that consumes nothing.
312
+ assertJobsRegistered("createApiJobs().start()");
298
313
  if (stopped()) return;
299
314
 
300
315
  if (!worker) {
@@ -315,6 +330,16 @@ async function startRuntime(
315
330
  }
316
331
 
317
332
  export function createApiJobs(config: JobsServerConfig): JobsApi {
333
+ // At ASSEMBLY, before anything else: a `jobs` that names nothing is a wiring
334
+ // error, and the factory is where the stack trace still points at the host
335
+ // module that wrote it.
336
+ assertJobsDeclared(config?.jobs);
337
+ // The same class of failure, one knob over: a retention window that is NaN
338
+ // or negative does not shrink retention, it stops bounding the backend at
339
+ // all — silently, weeks before anyone notices. The driver re-checks it, for
340
+ // a host that builds one directly off `@12-apps/jobs/bullmq`.
341
+ assertValidRetention(config?.retention);
342
+
318
343
  const state: RuntimeState = {
319
344
  starting: false,
320
345
  started: false,
@@ -340,9 +365,8 @@ export function createApiJobs(config: JobsServerConfig): JobsApi {
340
365
 
341
366
  async start(): Promise<void> {
342
367
  // 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.
368
+ // start that threw is not silently retried. Success is `startRuntime`'s
369
+ // to declare, as its last statement.
346
370
  if (state.starting) return;
347
371
  state.starting = true;
348
372
  // The whole env matrix is read HERE, at the same moment the driver
@@ -7,8 +7,10 @@
7
7
  * internal probes live.
8
8
  *
9
9
  * const jobsApi = createApiJobs({
10
- * jobs: () => import("./lib/jobs"), // defineJob modules
10
+ * jobs: () => import("./lib/jobs"), // defineJob modules — required,
11
+ * // and refused if it names nothing
11
12
  * db: () => getPrismaClient(), // the sweep_leases table
13
+ * events: { onJobFailed: … }, // dead-letters, for YOUR notifier
12
14
  * });
13
15
  * await jobsApi.start(); // at process start
14
16
  * app.route("/api/internal/jobs", jobsRouter(jobsApi)); // ./hono
@@ -24,4 +26,10 @@ export type {
24
26
  JobsResponse,
25
27
  JobsRoute,
26
28
  } from "./create-api-jobs";
29
+ export { JobsConfigError } from "./config";
27
30
  export type { JobsDriverChoice, JobsServerConfig, JobsSource } from "./config";
31
+ // Re-exported so a host that only imports `/server` can catch what the factory
32
+ // and `start()` throw without also reaching for the root entry point.
33
+ export { NoJobsRegisteredError } from "../core/registry";
34
+ export { InvalidJobRetentionError } from "../core/retention";
35
+ export type { JobEvents, JobRetention } from "../core/types";
@@ -4,13 +4,14 @@ import { createInlineJobDriver } from "../drivers/inline";
4
4
  import type { JobsDriverChoice, JobsServerConfig, ResolvedConfig } from "./config";
5
5
 
6
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.
7
+ * Driver resolution.
8
+ *
9
+ * The choice can arrive from the environment (`JOBS_DRIVER`) or through
10
+ * `config.driver` — a name or a ready-made instance. The resolution tracks
11
+ * WHERE the choice came from, validates the config path exactly as strictly as
12
+ * the env path, and names the right knob in every error message: an operator
13
+ * reading a `JOBS_DRIVER` message for a choice made in code would hunt for an
14
+ * env var that is not set.
14
15
  */
15
16
 
16
17
  /** A resolved choice, and which knob said so. */
@@ -136,7 +137,14 @@ async function resolveBullMq(
136
137
  // once the host bundles this package's published TS source.
137
138
  const { createBullMqJobDriver } = await import("../drivers/bullmq");
138
139
  return {
139
- driver: createBullMqJobDriver({ redisUrl, logger, prefix: resolved.queuePrefix }),
140
+ driver: createBullMqJobDriver({
141
+ redisUrl,
142
+ logger,
143
+ prefix: resolved.queuePrefix,
144
+ events: resolved.events,
145
+ retention: resolved.retention,
146
+ defaultConcurrency: resolved.defaultConcurrency,
147
+ }),
140
148
  deliberatelyOff: false,
141
149
  };
142
150
  } catch (error) {
@@ -165,11 +173,8 @@ export async function resolveDriver(
165
173
 
166
174
  if (choice === "off") {
167
175
  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.
176
+ // Logged in production even for an explicit off: production with no
177
+ // queue is worth a line in the log however it came about.
173
178
  logger.error(
174
179
  "No job driver: scheduled work will NOT run in this deployment. Set REDIS_URL.",
175
180
  );
@@ -195,7 +200,10 @@ export async function resolveDriver(
195
200
  // Detached on purpose: in a dev server the handler must not run inside
196
201
  // the request that enqueued it. A test that wants to await the handler
197
202
  // passes its own `createInlineJobDriver({ await: true })` instance.
198
- return { driver: createInlineJobDriver({ logger, await: false }), deliberatelyOff: false };
203
+ return {
204
+ driver: createInlineJobDriver({ logger, await: false, events: resolved.events }),
205
+ deliberatelyOff: false,
206
+ };
199
207
  }
200
208
 
201
209
  return resolveBullMq(named, resolved);