@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,9 +1,14 @@
1
1
  /**
2
2
  * The job registry: `defineJob` at module scope, jobs collected by import.
3
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.
4
+ * An open/closed seam — a domain module declares its jobs next to the code
5
+ * they belong to, and the worker bootstrap only has to import those modules.
6
+ * Nothing central lists them.
7
+ *
8
+ * The registry is also THE GATE. `resolveRegisteredJob` is the one question
9
+ * both halves of the system ask — the enqueue side before it writes, the
10
+ * execution side before it runs — so the two cannot drift into a state where
11
+ * a write is accepted that the run side will refuse.
7
12
  */
8
13
 
9
14
  import type {
@@ -31,18 +36,140 @@ export class DuplicateJobError extends Error {
31
36
  }
32
37
  }
33
38
 
39
+ /**
40
+ * Raised for a definition that cannot run as written — a blank name, an empty
41
+ * queue, a cron pattern no scheduler will ever fire.
42
+ *
43
+ * Every one of these fails SILENTLY if it is let through: an empty queue name
44
+ * creates a queue nobody consumes, `attempts: 0` means one attempt in one
45
+ * driver and none in another, and a blank pattern installs a scheduler that
46
+ * never fires — a sweep that simply stops happening, with nothing logged and
47
+ * nothing failed. So they are refused at assembly, where the stack trace still
48
+ * points at the declaration.
49
+ */
50
+ export class InvalidJobDefinitionError extends Error {
51
+ constructor(name: string, detail: string) {
52
+ super(`Job "${name}" is not valid: ${detail}`);
53
+ this.name = "InvalidJobDefinitionError";
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Raised when a process is asked to run jobs and NOT ONE is registered.
59
+ *
60
+ * "Declare nothing" must not resolve to "accept anything": with an empty
61
+ * registry a worker starts, consumes no queue, installs no schedule and
62
+ * reports itself healthy — every scheduled sweep in the deployment silently
63
+ * stops, and nothing fails to show it. A host with no jobs has no use for this
64
+ * package at all, so an empty registry is always a wiring mistake.
65
+ */
66
+ export class NoJobsRegisteredError extends Error {
67
+ constructor(where: string) {
68
+ super(
69
+ `${where}: no jobs are registered. Import the modules that call defineJob ` +
70
+ "(the import IS the registration), or list them in `jobs`.",
71
+ );
72
+ this.name = "NoJobsRegisteredError";
73
+ }
74
+ }
75
+
76
+ function assertText(name: string, field: string, value: unknown): void {
77
+ if (typeof value !== "string" || value.trim() === "") {
78
+ throw new InvalidJobDefinitionError(name, `\`${field}\` must be a non-empty string.`);
79
+ }
80
+ }
81
+
82
+ function assertPositiveInteger(name: string, field: string, value: unknown): void {
83
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
84
+ throw new InvalidJobDefinitionError(
85
+ name,
86
+ `\`${field}\` must be a positive integer, got ${JSON.stringify(value)}.`,
87
+ );
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Structural cron validation: five fields (or six, with seconds), none blank.
93
+ *
94
+ * Deliberately not a full cron parser — the driver's scheduler owns what a
95
+ * field may contain. What this catches is the class that is invisible at
96
+ * runtime: `""`, `" "`, `"@daily"` and `"0 *"` all satisfy a `string` type
97
+ * and all produce a schedule that never fires.
98
+ */
99
+ function assertCron(name: string, pattern: string): void {
100
+ assertText(name, "schedule.pattern", pattern);
101
+ const fields = pattern.trim().split(/\s+/);
102
+ if (fields.length !== 5 && fields.length !== 6) {
103
+ throw new InvalidJobDefinitionError(
104
+ name,
105
+ `\`schedule.pattern\` needs 5 fields (or 6 with seconds), got ${fields.length} in "${pattern}".`,
106
+ );
107
+ }
108
+ }
109
+
110
+ function assertBackoff(name: string, backoff: JobDefinition<never>["backoff"]): void {
111
+ if (!backoff) return;
112
+ const { type, delayMs } = backoff;
113
+ if (type !== "exponential" && type !== "fixed") {
114
+ throw new InvalidJobDefinitionError(
115
+ name,
116
+ `\`backoff.type\` must be "exponential" or "fixed", got ${JSON.stringify(type)}.`,
117
+ );
118
+ }
119
+ if (typeof delayMs !== "number" || !Number.isFinite(delayMs) || delayMs < 1) {
120
+ throw new InvalidJobDefinitionError(
121
+ name,
122
+ `\`backoff.delayMs\` must be a positive number of milliseconds, got ${JSON.stringify(delayMs)}.`,
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Refuse a definition that cannot run. Every check here guards a failure that
129
+ * is otherwise silent — see {@link InvalidJobDefinitionError}.
130
+ */
131
+ function assertValidDefinition(definition: JobDefinition<never>): void {
132
+ if (typeof definition !== "object" || definition === null) {
133
+ throw new InvalidJobDefinitionError("<unnamed>", "a definition object is required.");
134
+ }
135
+ assertText(String(definition.name), "name", definition.name);
136
+ const { name } = definition;
137
+ if (typeof definition.handle !== "function") {
138
+ throw new InvalidJobDefinitionError(name, "`handle` must be a function.");
139
+ }
140
+ // `queue: ""` is the sharp one: it is not nullish, so it never falls back to
141
+ // the default queue — it creates a queue named "" that no worker consumes.
142
+ if (definition.queue !== undefined) assertText(name, "queue", definition.queue);
143
+ if (definition.attempts !== undefined) {
144
+ assertPositiveInteger(name, "attempts", definition.attempts);
145
+ }
146
+ if (definition.concurrency !== undefined) {
147
+ assertPositiveInteger(name, "concurrency", definition.concurrency);
148
+ }
149
+ assertBackoff(name, definition.backoff);
150
+ if (definition.schedule !== undefined) {
151
+ assertCron(name, definition.schedule.pattern);
152
+ if (definition.schedule.timezone !== undefined) {
153
+ assertText(name, "schedule.timezone", definition.schedule.timezone);
154
+ }
155
+ }
156
+ }
157
+
34
158
  /**
35
159
  * Declare a job and register it under its name.
36
160
  *
37
161
  * Throws on a duplicate name rather than replacing: two modules quietly
38
162
  * 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*.)
163
+ * under that name would fire the wrong handler.
164
+ *
165
+ * Throws on an unusable definition too, for the same reason — both are silent
166
+ * if let through, and both are programming errors a deployment cannot recover
167
+ * from on its own.
42
168
  */
43
169
  export function defineJob<TPayload = void>(
44
170
  definition: JobDefinition<TPayload>,
45
171
  ): RegisteredJob<TPayload> {
172
+ assertValidDefinition(definition as unknown as JobDefinition<never>);
46
173
  if (registry.has(definition.name)) throw new DuplicateJobError(definition.name);
47
174
  registry.set(definition.name, definition as unknown as AnyJobDefinition);
48
175
 
@@ -68,6 +195,41 @@ export function findJob(name: string): AnyJobDefinition | undefined {
68
195
  return registry.get(name);
69
196
  }
70
197
 
198
+ /**
199
+ * THE GATE — "may this job run in this deployment?" — and the answer both
200
+ * sides of the queue key on.
201
+ *
202
+ * The enqueue path asks it before a write; the execution path asks it before a
203
+ * run. One function on purpose: restating the rule on each side is how a write
204
+ * comes to be accepted that the run side then refuses, which is the shape of a
205
+ * silent drop — the job lands in the backend, no handler claims it, it
206
+ * dead-letters, and the caller was told `enqueued: true`.
207
+ *
208
+ * Passing a DEFINITION checks identity, not just the name: an impostor object
209
+ * carrying a registered name would ship a payload the real handler never
210
+ * agreed to, and it is not the object a worker holds. Passing a NAME is what
211
+ * the execution side has — a wire key read back off the queue.
212
+ */
213
+ export function resolveRegisteredJob(
214
+ job: AnyJobDefinition | string,
215
+ ): AnyJobDefinition | undefined {
216
+ const name = typeof job === "string" ? job : job.name;
217
+ const registered = registry.get(name);
218
+ if (!registered) return undefined;
219
+ if (typeof job !== "string" && registered !== job) return undefined;
220
+ return registered;
221
+ }
222
+
223
+ /**
224
+ * Refuse an empty registry at the point of use. It takes the caller's name so
225
+ * the message says WHICH entry point was asked — the root `startJobWorkers`
226
+ * and the server factory both reach this, and an operator reading the log
227
+ * needs to know which of the two they wired.
228
+ */
229
+ export function assertJobsRegistered(where: string): void {
230
+ if (registry.size === 0) throw new NoJobsRegisteredError(where);
231
+ }
232
+
71
233
  /** Test-only: drop every registration (isolation between suites). */
72
234
  export function clearJobs(): void {
73
235
  registry.clear();
@@ -0,0 +1,68 @@
1
+ import type { JobRetention, JobRetentionWindow } from "./types";
2
+
3
+ /**
4
+ * Validation for the retention window a host may now configure.
5
+ *
6
+ * It lives in `core` rather than beside the BullMQ driver that consumes it so
7
+ * that the root entry point can re-export the error without dragging `bullmq`
8
+ * — and therefore ioredis — into a bundle that only ever enqueues. Nothing
9
+ * here imports a driver; it is arithmetic on four numbers.
10
+ */
11
+
12
+ /**
13
+ * Raised for a retention window that cannot bound anything.
14
+ *
15
+ * The negative case is the one worth the class. BullMQ's count trim runs
16
+ * `ZREMRANGEBYRANK` derived from the number it is given, so a NEGATIVE count
17
+ * removes one job per completion instead of holding the set at a ceiling —
18
+ * which is not "less retention", it is the UNBOUNDED completed-set that the
19
+ * default exists to prevent, arrived at by way of a config knob. Nothing
20
+ * throws, no probe reddens, and the symptom is a Redis that fills up weeks
21
+ * later and starts refusing writes.
22
+ *
23
+ * `NaN` is the likelier way in: `ageSeconds: Number(process.env.JOBS_KEEP_H)`
24
+ * with the variable unset is `NaN`, which every comparison in the trim silently
25
+ * answers false for.
26
+ */
27
+ export class InvalidJobRetentionError extends Error {
28
+ constructor(field: string, value: unknown) {
29
+ super(
30
+ `retention.${field} must be a positive finite number, got ${JSON.stringify(value)}. ` +
31
+ "A non-positive or NaN window does not shrink retention — it stops bounding the set.",
32
+ );
33
+ this.name = "InvalidJobRetentionError";
34
+ }
35
+ }
36
+
37
+ function assertWindow(half: string, window: JobRetentionWindow | undefined): void {
38
+ if (typeof window !== "object" || window === null) {
39
+ throw new InvalidJobRetentionError(half, window);
40
+ }
41
+ for (const field of ["ageSeconds", "count"] as const) {
42
+ const value = window[field];
43
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
44
+ throw new InvalidJobRetentionError(`${half}.${field}`, value);
45
+ }
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Refuse a retention that cannot bound the backend.
51
+ *
52
+ * Held to the same standard as every sibling numeric input — `attempts` and
53
+ * `concurrency` go through `assertPositiveInteger`, `backoff.delayMs` through
54
+ * a finite check, and the driver's `defaultConcurrency` is sanitised with a
55
+ * `> 0` guard. Retention was the one configurable number with no check on any
56
+ * path, and it is the only one whose failure is invisible.
57
+ *
58
+ * `undefined` is fine and means "use the package default"; it is the values a
59
+ * host actually spells out that are checked.
60
+ */
61
+ export function assertValidRetention(retention: JobRetention | undefined): void {
62
+ if (retention === undefined) return;
63
+ if (typeof retention !== "object" || retention === null) {
64
+ throw new InvalidJobRetentionError("<root>", retention);
65
+ }
66
+ assertWindow("completed", retention.completed);
67
+ assertWindow("failed", retention.failed);
68
+ }
@@ -6,9 +6,15 @@
6
6
  * consume), a CONSUMER (the worker: it does both), or neither (a unit test).
7
7
  * `configureJobs` installs the driver; `startJobWorkers` is the extra step a
8
8
  * consumer takes.
9
+ *
10
+ * This is a PUBLIC entry point, not merely `createApiJobs`'s plumbing: the
11
+ * package root exports all of it, and a host that wires the runtime by hand
12
+ * reaches every hazard the factory does. So the guards live HERE and the
13
+ * factory inherits them, rather than the newest surface owning a second copy
14
+ * the older path never got.
9
15
  */
10
16
 
11
- import { listJobs } from "./registry";
17
+ import { assertJobsRegistered, listJobs, resolveRegisteredJob } from "./registry";
12
18
  import type {
13
19
  AnyJobDefinition,
14
20
  EnqueueOptions,
@@ -51,18 +57,31 @@ export function getJobLogger(): JobLogger {
51
57
  /**
52
58
  * Defer one run of `definition`.
53
59
  *
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
+ * **Never throws.** Every caller of this has just committed a durable row, and
61
+ * the enqueue is only the fast path to acting on it. The backend being down
62
+ * must degrade that to "a sweep will pick it up in a few minutes", not fail
63
+ * the request that triggered it. The failure is logged loudly and reported in
64
+ * the result for callers that care.
65
+ *
66
+ * It does REFUSE an unregistered definition, through the same
67
+ * `resolveRegisteredJob` gate the execution side calls. Without that, a write
68
+ * is accepted no worker can ever claim: the job lands in the backend, the
69
+ * consumer finds no handler for the name, the run dead-letters, and the caller
70
+ * was told `enqueued: true`. Refusing at the write is louder, and loses
71
+ * nothing that was not already lost.
60
72
  */
61
73
  export async function enqueueJob(
62
74
  definition: AnyJobDefinition,
63
75
  payload: unknown,
64
76
  options: EnqueueOptions = {},
65
77
  ): Promise<EnqueueResult> {
78
+ if (!resolveRegisteredJob(definition)) {
79
+ logger.error(
80
+ `"${definition.name}" was not enqueued: it is not the job registered under that ` +
81
+ "name. Enqueue the value defineJob() returned, and import its module first.",
82
+ );
83
+ return { enqueued: false, reason: "unregistered" };
84
+ }
66
85
  if (!driver) {
67
86
  logger.warn(
68
87
  `"${definition.name}" was not enqueued: no driver is configured in this process.`,
@@ -80,9 +99,14 @@ export async function enqueueJob(
80
99
  /**
81
100
  * Start consuming every registered job and install their cron schedules.
82
101
  *
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.
102
+ * Idempotent: a second call is a no-op, so a bootstrap that runs twice cannot
103
+ * double-consume.
104
+ *
105
+ * Refuses an EMPTY registry. A worker that consumes nothing is not a degraded
106
+ * deployment but a broken one that looks fine: no queue is read, no schedule
107
+ * is installed, and the process reports itself started. "The host declared no
108
+ * jobs" and "the host's job modules were never imported" are the same fact
109
+ * from here, and the second is the common one.
86
110
  */
87
111
  export async function startJobWorkers(): Promise<void> {
88
112
  if (!driver) throw new Error("startJobWorkers(): no driver configured.");
@@ -90,6 +114,7 @@ export async function startJobWorkers(): Promise<void> {
90
114
  logger.warn("startJobWorkers() called twice; ignoring the second call.");
91
115
  return;
92
116
  }
117
+ assertJobsRegistered("startJobWorkers()");
93
118
  started = true;
94
119
 
95
120
  const definitions = listJobs();
package/src/core/types.ts CHANGED
@@ -4,20 +4,18 @@
4
4
  * Three layers, each replaceable without touching the others:
5
5
  * - DEFINITIONS describe a unit of deferred work: its name, its retry
6
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.
7
+ * - The REGISTRY collects definitions at import time so a worker process can
8
+ * start every job by importing the modules that define them.
10
9
  * - A DRIVER executes them. BullMQ/Redis in production, inline in tests.
11
10
  *
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.
11
+ * Nothing here imports Redis, an ORM, or an application's domain. A job
12
+ * handler receives a plain payload and is expected to re-read whatever it
13
+ * needs from the database — see the payload rule below.
15
14
  */
16
15
 
17
16
  /**
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.
17
+ * The logging port. Structurally satisfied by a winston-style logger and by
18
+ * `console`, so the library needs no logging dependency of its own.
21
19
  */
22
20
  export interface JobLogger {
23
21
  info(message: string, ...meta: unknown[]): void;
@@ -37,12 +35,17 @@ export interface JobBackoff {
37
35
 
38
36
  /** A cron schedule for a repeatable job. */
39
37
  export interface JobSchedule {
40
- /** Standard 5-field cron expression. */
38
+ /**
39
+ * Standard 5-field cron expression (a 6-field form carrying seconds is
40
+ * accepted too). Validated when the job is defined: a blank or malformed
41
+ * pattern installs a scheduler that never fires, which is a sweep that
42
+ * silently never runs.
43
+ */
41
44
  pattern: string;
42
45
  /**
43
46
  * 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.
47
+ * that means "03:00 local" must say WHICH local, because a server's own zone
48
+ * is not a product decision.
46
49
  */
47
50
  timezone?: string;
48
51
  }
@@ -68,17 +71,17 @@ export type JobHandler<TPayload> = (
68
71
  *
69
72
  * ## The payload rule
70
73
  *
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
+ * A payload carries IDENTIFIERS, never state. `{ documentId }`, not the
75
+ * rendered document; `{ accountId, periodStart }`, not the amount to charge.
76
+ * Two reasons, and both are load-bearing:
74
77
  *
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.
78
+ * 1. **The queue is not the source of truth.** The database is. A payload
79
+ * that duplicates a row's contents is a second copy that can disagree
80
+ * with it — and the copy is the one that gets acted on, days later, after
81
+ * the row changed. Re-reading inside the handler is always correct.
82
+ * 2. **The queue can be lost.** A flushed or evicted backend must cost a
83
+ * delayed run, not a lost or corrupted business fact. Pair every job with
84
+ * a durable row a sweep can find again.
82
85
  *
83
86
  * ## Idempotency
84
87
  *
@@ -92,8 +95,8 @@ export interface JobDefinition<TPayload = void> {
92
95
  /** Dot-namespaced and stable: it is the wire key and the scheduler id. */
93
96
  name: string;
94
97
  /**
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
98
+ * Which queue carries it. One queue (`DEFAULT_QUEUE`) for everything is the
99
+ * right shape at most scales — one worker, one pair of connections, one
97
100
  * dashboard. Move a noisy or slow job onto its own queue when it starts
98
101
  * starving the others; no call site changes when you do.
99
102
  */
@@ -121,7 +124,8 @@ export interface EnqueueOptions {
121
124
  /**
122
125
  * Collapses duplicates: while a job with this key is waiting, delayed or
123
126
  * active, enqueueing it again is a no-op. Scope it to the work, not the
124
- * caller — `notification:<id>`, `low-stock:<itemId>:<date>`.
127
+ * caller — `<job>:<entity id>`, or `<job>:<entity id>:<date>` for a job that
128
+ * may legitimately repeat daily.
125
129
  *
126
130
  * NOT a durability mechanism. The key is forgotten once the job completes
127
131
  * and is cleaned up, so it stops accidental double-sends within a window,
@@ -133,8 +137,15 @@ export interface EnqueueOptions {
133
137
  delayMs?: number;
134
138
  }
135
139
 
136
- /** Why an enqueue did not reach a queue. */
137
- export type EnqueueSkipReason = "no-driver" | "duplicate" | "error";
140
+ /**
141
+ * Why an enqueue did not reach a queue.
142
+ *
143
+ * `unregistered` is the one that is a PROGRAMMING error rather than an
144
+ * operational one: the definition handed in is not the one the registry holds
145
+ * under that name, so no worker in this deployment could ever run it. See
146
+ * `enqueueJob`.
147
+ */
148
+ export type EnqueueSkipReason = "no-driver" | "duplicate" | "error" | "unregistered";
138
149
 
139
150
  /**
140
151
  * The result of an enqueue.
@@ -148,6 +159,88 @@ export interface EnqueueResult {
148
159
  reason?: EnqueueSkipReason;
149
160
  }
150
161
 
162
+ /** What happened to one attempt of one job. */
163
+ export interface JobRunEvent {
164
+ name: string;
165
+ /** The queue that carried it, already resolved — never `undefined`. */
166
+ queue: string;
167
+ /** The driver's id for the run, matching {@link JobContext.runId}. */
168
+ runId: string;
169
+ /** 1-based attempt number. */
170
+ attempt: number;
171
+ maxAttempts: number;
172
+ }
173
+
174
+ /** A job that finished successfully. */
175
+ export type JobCompletedEvent = JobRunEvent;
176
+
177
+ /** A job attempt that threw. */
178
+ export interface JobFailedEvent extends JobRunEvent {
179
+ error: unknown;
180
+ /**
181
+ * No further attempt will be made — the attempt budget is spent, or the
182
+ * failure was unrecoverable. This is the DEAD-LETTER signal, and the one
183
+ * moment a host may want to notify someone, open a ticket or page an
184
+ * operator. A non-terminal failure is ordinary retry noise.
185
+ */
186
+ terminal: boolean;
187
+ }
188
+
189
+ /** A cron schedule that existed in the backend and no longer exists in code. */
190
+ export interface ScheduleRemovedEvent {
191
+ /** The scheduler key, which is the name of the job that installed it. */
192
+ name: string;
193
+ queue: string;
194
+ }
195
+
196
+ /**
197
+ * The observation port — how the rest of a system finds out what the queue did.
198
+ *
199
+ * This package deliberately does NOT notify, audit or publish anything itself:
200
+ * it has no opinion about who should hear that a job dead-lettered, and
201
+ * reaching for a notification package here would make every host inherit that
202
+ * opinion (and that dependency). What it owns is the MOMENT, so the moment is
203
+ * exported and the host wires the consequence.
204
+ *
205
+ * Every hook is optional, and a hook that throws is logged and swallowed — an
206
+ * observer must never fail the job it is observing, nor the reconcile that
207
+ * removed a stale schedule.
208
+ */
209
+ export interface JobEvents {
210
+ /** A job finished successfully. The seam for a realtime "it is done" event. */
211
+ onJobCompleted?(event: JobCompletedEvent): void | Promise<void>;
212
+ /**
213
+ * An attempt failed. Check `terminal` — that is the dead-letter, and the
214
+ * only one of the two most hosts want to act on.
215
+ */
216
+ onJobFailed?(event: JobFailedEvent): void | Promise<void>;
217
+ /**
218
+ * A schedule was removed from the backend because no code declares it any
219
+ * more. Destructive and unrecoverable from the queue's side, which is why it
220
+ * is the schedule event worth auditing; installation is an idempotent upsert
221
+ * that happens on every boot, so auditing that would write noise per deploy.
222
+ */
223
+ onScheduleRemoved?(event: ScheduleRemovedEvent): void | Promise<void>;
224
+ }
225
+
226
+ /** How long finished jobs are kept in the backend before they are trimmed. */
227
+ export interface JobRetentionWindow {
228
+ ageSeconds: number;
229
+ count: number;
230
+ }
231
+
232
+ /**
233
+ * Retention for finished jobs. Bounded on purpose: an unbounded completed-set
234
+ * is the classic way a small queue backend fills up and starts refusing
235
+ * writes. The package default keeps a day of successes (enough to answer "did
236
+ * it run?") and a week of failures (enough to debug one); a host whose support
237
+ * window is longer says so rather than forking the driver.
238
+ */
239
+ export interface JobRetention {
240
+ completed: JobRetentionWindow;
241
+ failed: JobRetentionWindow;
242
+ }
243
+
151
244
  /**
152
245
  * The driver port. `inline` and `bullmq` implement it; a third (SQS, pg-boss)
153
246
  * would need no change above this line.
@@ -0,0 +1,86 @@
1
+ import { UnrecoverableError, type JobsOptions } from "bullmq";
2
+
3
+ import { assertValidRetention } from "../core/retention";
4
+ import type { AnyJobDefinition, JobRetention } from "../core/types";
5
+
6
+ /**
7
+ * The BullMQ driver's three policy decisions, kept together and out of the
8
+ * driver body — retention, concurrency and what counts as a dead-letter.
9
+ *
10
+ * All three share a property that makes them worth their own file and their
11
+ * own tests: each is SILENT when it is wrong. Nothing throws, no probe turns
12
+ * red, and the only symptom is behaviour nobody is watching — a queue that
13
+ * filled up, a sweep that quietly stopped being single-flight, a pager that
14
+ * never went off.
15
+ */
16
+
17
+ /** Worker concurrency when no definition on the queue asks for a specific one. */
18
+ export const DEFAULT_CONCURRENCY = 5;
19
+
20
+ /**
21
+ * The package's retention default: a day of successes is enough to answer "did
22
+ * it run?", a week of failures is enough to debug one. Bounded on purpose — an
23
+ * unbounded completed-set is the classic way a small Redis fills up and starts
24
+ * refusing writes. A host with a different support window passes its own
25
+ * {@link JobRetention} rather than forking the driver.
26
+ */
27
+ export const DEFAULT_JOB_RETENTION: JobRetention = {
28
+ completed: { ageSeconds: 24 * 3600, count: 1_000 },
29
+ failed: { ageSeconds: 7 * 24 * 3600, count: 5_000 },
30
+ };
31
+
32
+ /**
33
+ * Validated HERE, at the single funnel both paths cross, rather than only at
34
+ * the server factory: `@12-apps/jobs/bullmq` is a published entry point, so a
35
+ * host that builds the driver itself would otherwise reach BullMQ with an
36
+ * unchecked window. `createApiJobs` checks the same thing at assembly so the
37
+ * failure lands on the line that wrote it, and this is the backstop that makes
38
+ * the check a property of the driver rather than of the newest caller.
39
+ */
40
+ export function retentionOptions(
41
+ retention: JobRetention,
42
+ ): Pick<JobsOptions, "removeOnComplete" | "removeOnFail"> {
43
+ assertValidRetention(retention);
44
+ return {
45
+ removeOnComplete: {
46
+ age: retention.completed.ageSeconds,
47
+ count: retention.completed.count,
48
+ },
49
+ removeOnFail: { age: retention.failed.ageSeconds, count: retention.failed.count },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * A queue's worker concurrency.
55
+ *
56
+ * The default applies only when NO definition on the queue asked for a
57
+ * specific number. A stated `concurrency: 1` means single-flight and must be
58
+ * honoured — taking `max(default, …)` would quietly raise it back to the
59
+ * default and undo the very property the caller asked for.
60
+ */
61
+ export function resolveConcurrency(
62
+ group: readonly AnyJobDefinition[],
63
+ fallback: number = DEFAULT_CONCURRENCY,
64
+ ): number {
65
+ const stated = group
66
+ .map((definition) => definition.concurrency)
67
+ .filter((value): value is number => typeof value === "number" && value > 0);
68
+ return stated.length > 0 ? Math.max(...stated) : fallback;
69
+ }
70
+
71
+ /**
72
+ * Is this the last thing that will happen to the job?
73
+ *
74
+ * The whole value of `onJobFailed` rests on this bit: a host pages someone on
75
+ * a dead-letter and must not page them on every retry. Two ways to be
76
+ * terminal — the attempt budget is spent, or the failure was declared
77
+ * unrecoverable (a name no handler claims is the common one, and BullMQ never
78
+ * retries it).
79
+ */
80
+ export function isTerminalFailure(
81
+ attemptsMade: number,
82
+ maxAttempts: number,
83
+ error: unknown,
84
+ ): boolean {
85
+ return attemptsMade >= maxAttempts || error instanceof UnrecoverableError;
86
+ }