@12-apps/jobs 4.1.0 → 4.3.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/ADOPTING.md CHANGED
@@ -19,6 +19,59 @@ arrive as config. What the package owns is the RUNNING of them.
19
19
  | **Drivers** | `@12-apps/jobs/bullmq`, `@12-apps/jobs/inline` | Nothing, usually — `createApiJobs` resolves them. Import directly only to hand a pre-built instance in (`driver: createInlineJobDriver({ await: true })` in a test). |
20
20
  | **Prisma** | `prisma/jobs.prisma` + `prisma/migrations/*` | Sync BOTH into the host's schema/migrations folders as **copies** (see below). One table: `sweep_leases`. |
21
21
 
22
+ ## `defineJobModule` — how another PACKAGE ships background work
23
+
24
+ A package that owns a domain owns the deferred half of it too: the sweep that
25
+ finishes what a request could not, the watcher that waits for a callback, the
26
+ expiry that stops waiting. `defineJob` cannot express that — it registers at
27
+ module scope, so the handler would have to close over a database client, a
28
+ gateway and a logger the package cannot know at import time.
29
+
30
+ So a package declares **blueprints** (everything about a job except the deps)
31
+ and the host supplies the deps in one line — the worker counterpart of
32
+ `mountPayments(config) → { routes }`:
33
+
34
+ ```ts
35
+ // in the package, beside the domain it belongs to
36
+ export const paymentsJobs = defineJobModule<PaymentsJobDeps>()({
37
+ namespace: 'payments',
38
+ jobs: {
39
+ watchSettlement: {
40
+ name: 'watch-settlement', // → `payments.watch-settlement`
41
+ attempts: 3,
42
+ backoff: { type: 'exponential', delayMs: 5_000 },
43
+ handle: async ({ chargeId }, deps) => deps.gateway.refreshCharge(chargeId),
44
+ },
45
+ },
46
+ });
47
+
48
+ // in the host, beside the endpoint mount it already writes
49
+ const { jobs } = paymentsJobs.mount({ gateway, store, logger });
50
+ createApiJobs({ jobs: [...jobs, ...ownJobs] });
51
+ ```
52
+
53
+ Three properties this buys, each of which a host could otherwise satisfy by
54
+ accident and lose silently:
55
+
56
+ - **Importing the package registers nothing.** Work reaches a registry only
57
+ when a host mounts it.
58
+ - **The host is never asked for the policy.** Retries, backoff, queue,
59
+ schedule and concurrency are the package's decisions, exactly as the HTTP
60
+ methods of a settings route are. A host that had to restate them is a host
61
+ that can get them wrong.
62
+ - **The package can enqueue its own work.**
63
+ `paymentsJobs.enqueue.watchSettlement(…)` resolves through the registry by
64
+ NAME at call time, so it works from a module that holds no deps and was
65
+ imported long before the mount. Enqueueing before a mount reports
66
+ `unregistered` and logs — it never throws, because the emit site is usually a
67
+ request path (a charge being raised) and a wiring mistake in the deferred
68
+ half must not fail the money path in front of it.
69
+
70
+ Names are namespaced here, once: a blueprint states `watch-settlement` and the
71
+ module prepends `payments.`. Two packages therefore cannot collide by both
72
+ calling something `drain`, and a queue dashboard says whose work it is. A blank
73
+ or dotted namespace is refused at declaration.
74
+
22
75
  ## Why there is no `createWebJobs`
23
76
 
24
77
  The porting contract asks for both halves — `createApiFoo` and `createWebFoo`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/jobs",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "type": "module",
5
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 and zero-config dev). The runtime half (./server) is one factory — createApiJobs: driver resolution, worker bootstrap, graceful drain, the single-writer sweep lease and a health endpoint — with a Hono adapter (./hono) and the package-owned SweepLease Prisma partial + migrations. Knows nothing about the host app's domain, ORM or transport.",
6
6
  "exports": {
@@ -9,6 +9,7 @@
9
9
  "./hono": "./src/hono/index.ts",
10
10
  "./bullmq": "./src/drivers/bullmq.ts",
11
11
  "./inline": "./src/drivers/inline.ts",
12
+ "./manifest": "./src/manifest/index.ts",
12
13
  "./package.json": "./package.json"
13
14
  },
14
15
  "scripts": {
@@ -23,7 +24,7 @@
23
24
  "bullmq": "^5.81.2"
24
25
  },
25
26
  "devDependencies": {
26
- "@12-apps/eslint-config": "^1.20.0",
27
+ "@12-apps/eslint-config": "^1.21.0",
27
28
  "@12-apps/typescript-config": "^1.20.0",
28
29
  "eslint": "^9.39.1",
29
30
  "eslint-plugin-test-flakiness": "^1.4.0",
@@ -68,5 +69,26 @@
68
69
  "!**/*.stories.*",
69
70
  "!**/*.test-story.*",
70
71
  "!**/test-helpers.*"
71
- ]
72
+ ],
73
+ "wiring": {
74
+ "env": [
75
+ {
76
+ "name": "REDIS_URL",
77
+ "description": "Set it and the queue turns itself on; unset selects the inline/off driver."
78
+ },
79
+ {
80
+ "name": "JOBS_DRIVER",
81
+ "description": "bullmq | inline | off; unset picks by REDIS_URL and NODE_ENV."
82
+ },
83
+ {
84
+ "name": "JOBS_WORKER",
85
+ "scope": "worker",
86
+ "description": "\"1\" on the ONE process that consumes the queue and runs schedules."
87
+ },
88
+ {
89
+ "name": "JOBS_QUEUE_PREFIX",
90
+ "description": "Optional key prefix, to share one Redis across environments."
91
+ }
92
+ ]
93
+ }
72
94
  }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * `defineJobModule` — how a PACKAGE exposes background work.
3
+ *
4
+ * This is the worker counterpart of an endpoint mount, and it exists for the
5
+ * same reason that one does. A package that owns a domain owns the DEFERRED
6
+ * half of it too: the sweep that finishes what a request could not, the watcher
7
+ * that waits for a callback, the expiry that stops waiting. Left to each host,
8
+ * that half gets rewritten per adopter — and every rewrite is a place the
9
+ * behaviour can differ, which is exactly what happened to the payments
10
+ * reconciliation: one host had it, the others silently did not.
11
+ *
12
+ * ## The problem this solves
13
+ *
14
+ * `defineJob` registers at MODULE SCOPE. That is right for a host's own jobs,
15
+ * where the handler can close over whatever it needs, and impossible for a
16
+ * package's: a package cannot know the host's database client, gateway or
17
+ * logger at import time, and must not reach for a global to find them.
18
+ *
19
+ * So a package declares BLUEPRINTS — everything about a job except the deps —
20
+ * and the host supplies the deps at mount:
21
+ *
22
+ * // in the package
23
+ * export const paymentsJobs = defineJobModule<PaymentsJobDeps>()({
24
+ * namespace: "payments",
25
+ * jobs: { watchSettlement, expireWatches },
26
+ * });
27
+ *
28
+ * // in the host, beside the endpoint mount it already writes
29
+ * const { jobs } = paymentsJobs.mount({ gateway, store, logger });
30
+ * createApiJobs({ jobs: [...jobs, ...ownJobs] });
31
+ *
32
+ * The host's line is the whole integration. It cannot get the retry policy,
33
+ * the schedule, the queue or the concurrency wrong, because it is not asked
34
+ * for them — the same reason `mountPayments` does not ask a host which HTTP
35
+ * methods a settings route answers.
36
+ *
37
+ * ## Enqueueing without the deps
38
+ *
39
+ * A package's own code has to be able to enqueue its own jobs — a charge is
40
+ * raised, and something must start watching it — long after `mount` ran and
41
+ * from a module that holds no deps. `enqueue` is therefore a LAZY handle: it
42
+ * resolves through the registry by name at call time, exactly as an emit site
43
+ * does, so it works from anywhere and needs nothing threaded through.
44
+ *
45
+ * await paymentsJobs.enqueue.watchSettlement({ chargeId }, { dedupeKey });
46
+ *
47
+ * Enqueueing before `mount` reports `unregistered` rather than throwing — the
48
+ * same answer `enqueueJob` gives for any unknown name, and the same reason: a
49
+ * deferred side effect must never take down the request that scheduled it. A
50
+ * host that forgot to mount sees it in the log and in the skip reason, while
51
+ * the durable row the job was paired with is still there for the sweep.
52
+ *
53
+ * ## Names are namespaced here, once
54
+ *
55
+ * A blueprint states its own short name (`watch-settlement`) and this prepends
56
+ * the namespace (`payments.watch-settlement`). The wire key is then derived in
57
+ * one place rather than spelled in each declaration, so two packages cannot
58
+ * collide by both calling something `drain`, and a host reading its queue
59
+ * dashboard can tell whose work it is looking at.
60
+ */
61
+
62
+ import { defineJob, resolveRegisteredJob, type RegisteredJob } from "./registry";
63
+ import { enqueueJob, getJobLogger } from "./runtime";
64
+ import type {
65
+ EnqueueOptions,
66
+ EnqueueResult,
67
+ JobBackoff,
68
+ JobContext,
69
+ JobSchedule,
70
+ } from "./types";
71
+
72
+ /**
73
+ * One job a package declares, with its deps left open.
74
+ *
75
+ * Everything `JobDefinition` carries except `name` (namespaced at mount) and
76
+ * `handle`, whose signature gains the host-supplied deps. A blueprint is inert
77
+ * data: declaring one registers nothing and starts nothing.
78
+ */
79
+ export interface JobBlueprint<TPayload, TDeps> {
80
+ /**
81
+ * Short name within the module — `watch-settlement`, not
82
+ * `payments.watch-settlement`. The namespace is prepended at mount.
83
+ */
84
+ name: string;
85
+ queue?: string;
86
+ attempts?: number;
87
+ backoff?: JobBackoff;
88
+ schedule?: JobSchedule;
89
+ concurrency?: number;
90
+ /**
91
+ * The work. Takes the host's deps as its second argument, which is the only
92
+ * difference from a plain {@link JobDefinition} handler — the payload rule
93
+ * and the idempotency contract are unchanged and still apply.
94
+ */
95
+ handle: (payload: TPayload, deps: TDeps, context: JobContext) => Promise<void>;
96
+ }
97
+
98
+ /** A record of blueprints, keyed by however the package refers to them. */
99
+ export type JobBlueprints<TDeps> = Readonly<
100
+ Record<string, JobBlueprint<never, TDeps>>
101
+ >;
102
+
103
+ /** The payload a blueprint carries, recovered for the enqueue signature. */
104
+ type PayloadOf<TBlueprint> =
105
+ TBlueprint extends JobBlueprint<infer TPayload, never> ? TPayload : never;
106
+
107
+ /**
108
+ * What `mount` answers: the registered jobs, ready to hand to `createApiJobs`.
109
+ *
110
+ * An object rather than a bare array, and for the same reason
111
+ * `MountedPaymentsRoutes` is one: a mount grows things a host needs alongside
112
+ * the primary artefact, and widening an object is not a breaking change.
113
+ */
114
+ export interface MountedJobs {
115
+ /** Pass straight to `createApiJobs({ jobs })`, spread beside the host's own. */
116
+ readonly jobs: readonly RegisteredJob<never>[];
117
+ /** The wire names this mount registered — for a host's own health output. */
118
+ readonly names: readonly string[];
119
+ }
120
+
121
+ /** The lazy enqueuers, one per blueprint, keyed as the blueprints were. */
122
+ export type JobModuleEnqueue<TBlueprints> = {
123
+ readonly [K in keyof TBlueprints]: (
124
+ payload: PayloadOf<TBlueprints[K]>,
125
+ options?: EnqueueOptions,
126
+ ) => Promise<EnqueueResult>;
127
+ };
128
+
129
+ /** A package's deferred surface: mount it once, enqueue from anywhere. */
130
+ export interface JobModule<TDeps, TBlueprints extends JobBlueprints<TDeps>> {
131
+ /** `payments`, `notifications`, … — the prefix on every name this owns. */
132
+ readonly namespace: string;
133
+ /** Register every job with the host's deps bound in. Call once per process. */
134
+ mount(deps: TDeps): MountedJobs;
135
+ /** Defer one run, resolved through the registry at call time. */
136
+ readonly enqueue: JobModuleEnqueue<TBlueprints>;
137
+ /** The wire name of a blueprint, for logs and dedupe keys. */
138
+ nameOf(key: keyof TBlueprints): string;
139
+ }
140
+
141
+ /**
142
+ * Declare a package's deferred surface.
143
+ *
144
+ * Curried so the deps type is stated once and every blueprint is checked
145
+ * against it, while the blueprint record's own keys and payload types stay
146
+ * inferred — TypeScript cannot do both in one call.
147
+ */
148
+ export function defineJobModule<TDeps>() {
149
+ return function build<TBlueprints extends JobBlueprints<TDeps>>(options: {
150
+ namespace: string;
151
+ jobs: TBlueprints;
152
+ }): JobModule<TDeps, TBlueprints> {
153
+ const { namespace, jobs } = options;
154
+ assertNamespace(namespace);
155
+ const keys = Object.keys(jobs) as (keyof typeof jobs)[];
156
+ const wireName = (key: keyof typeof jobs): string =>
157
+ `${namespace}.${jobs[key]?.name ?? String(key)}`;
158
+
159
+ return {
160
+ namespace,
161
+ nameOf: wireName,
162
+
163
+ mount(deps: TDeps): MountedJobs {
164
+ const registered = keys.map((key) => {
165
+ const blueprint = jobs[key] as JobBlueprint<never, TDeps>;
166
+ return defineJob<never>({
167
+ name: wireName(key),
168
+ ...(blueprint.queue === undefined ? {} : { queue: blueprint.queue }),
169
+ ...(blueprint.attempts === undefined ? {} : { attempts: blueprint.attempts }),
170
+ ...(blueprint.backoff === undefined ? {} : { backoff: blueprint.backoff }),
171
+ ...(blueprint.schedule === undefined ? {} : { schedule: blueprint.schedule }),
172
+ ...(blueprint.concurrency === undefined
173
+ ? {}
174
+ : { concurrency: blueprint.concurrency }),
175
+ handle: (payload, context) => blueprint.handle(payload, deps, context),
176
+ });
177
+ });
178
+ return {
179
+ jobs: registered,
180
+ names: registered.map((job) => job.name),
181
+ };
182
+ },
183
+
184
+ enqueue: Object.fromEntries(
185
+ keys.map((key) => [
186
+ key,
187
+ // Resolved at CALL time, not at mount: this handle is what the
188
+ // package's own emit sites hold, and they are imported long before
189
+ // (and often without) the mount that binds the deps.
190
+ async (payload: never, enqueueOptions?: EnqueueOptions) => {
191
+ const name = wireName(key);
192
+ const definition = resolveRegisteredJob(name);
193
+ if (!definition) return unmounted(name);
194
+ return enqueueJob(definition, payload, enqueueOptions);
195
+ },
196
+ ]),
197
+ ) as JobModuleEnqueue<TBlueprints>,
198
+ };
199
+ };
200
+ }
201
+
202
+ /**
203
+ * A namespace that cannot silently produce a bad wire name.
204
+ *
205
+ * A blank one yields `.watch-settlement`, which registers, schedules and runs
206
+ * — and is indistinguishable in a queue dashboard from another package's. A
207
+ * dot inside one yields a name with two, which no host can parse back.
208
+ */
209
+ function assertNamespace(namespace: string): void {
210
+ if (typeof namespace !== "string" || namespace.trim() === "") {
211
+ throw new TypeError("defineJobModule: `namespace` must be a non-empty string.");
212
+ }
213
+ if (namespace.includes(".")) {
214
+ throw new TypeError(
215
+ `defineJobModule: \`namespace\` must not contain a dot, got "${namespace}".`,
216
+ );
217
+ }
218
+ }
219
+
220
+ /**
221
+ * The answer for an enqueue that arrived before `mount` — or without one.
222
+ *
223
+ * A skip rather than a throw, because this is reached from wherever the
224
+ * package's domain happens to enqueue: a charge being raised, a webhook being
225
+ * accepted. Those are request paths, and a wiring mistake in the deferred half
226
+ * must not fail the money path in front of it. The log line is what makes the
227
+ * mistake findable; `unregistered` is what `enqueueJob` itself answers for the
228
+ * same condition, so a caller has one reason code to handle, not two.
229
+ */
230
+ function unmounted(name: string): EnqueueResult {
231
+ getJobLogger().error(
232
+ `"${name}" was not enqueued: its job module is not mounted in this process. ` +
233
+ "Call the module's mount() and pass its jobs to createApiJobs({ jobs }).",
234
+ );
235
+ return { enqueued: false, reason: "unregistered" };
236
+ }
package/src/index.ts CHANGED
@@ -55,6 +55,21 @@ export {
55
55
  } from "./core/registry";
56
56
  export type { RegisteredJob } from "./core/registry";
57
57
 
58
+ /**
59
+ * The PACKAGE-side seam — the worker counterpart of an endpoint mount. A
60
+ * package declares blueprints whose handlers take host deps; the host binds
61
+ * them in one line and spreads the result into `createApiJobs({ jobs })`. See
62
+ * `core/module.ts` for why a package cannot reach for `defineJob` directly.
63
+ */
64
+ export { defineJobModule } from "./core/module";
65
+ export type {
66
+ JobBlueprint,
67
+ JobBlueprints,
68
+ JobModule,
69
+ JobModuleEnqueue,
70
+ MountedJobs,
71
+ } from "./core/module";
72
+
58
73
  export {
59
74
  configureJobs,
60
75
  enqueueJob,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `@12-apps/jobs/manifest` — the SHARED wiring manifest.
3
+ *
4
+ * A pure-data manifest: identity plus the env surface, nothing else. The
5
+ * runtime capabilities deliberately stay UNDECLARED for now — hosts mount
6
+ * this package directly (`createJobs`, the Hono adapter) rather than through
7
+ * the wiring consumer, and inventorying `server` here would oblige every one
8
+ * of them to adopt or decline it. The env declaration carries its own weight
9
+ * today: the exact `process.env` keys the queue reads, mirrored into
10
+ * `package.json` for tooling that cannot execute TypeScript, so a host's
11
+ * `.env.example` can stop rotting by hand.
12
+ *
13
+ * Every key is OPTIONAL by this package's own design: absence selects the
14
+ * inline/off driver and logs the decision — it never throws. Config always
15
+ * wins over env (`config.x ?? process.env.Y`). `NODE_ENV` is read too, but
16
+ * it is platform vocabulary, not a contribution.
17
+ *
18
+ * This package depends on `@12-apps/wiring` in NEITHER direction — wiring
19
+ * already devDepends on jobs for its blueprint-compat suite, so an edge back
20
+ * would close a turbo build cycle. The manifest is therefore untyped pure
21
+ * data here, and the producer assertions (defineManifest, both mirrors, the
22
+ * exports tripwire) run in WIRING's own suite, which imports this subpath —
23
+ * the same "fails in a test run before any host sees it" guarantee, one
24
+ * package over, along the dependency edge that already exists.
25
+ */
26
+
27
+ export const jobsManifest = {
28
+ name: "@12-apps/jobs",
29
+ contract: 1,
30
+ env: [
31
+ { name: "REDIS_URL", description: "Set it and the queue turns itself on; unset selects the inline/off driver." },
32
+ { name: "JOBS_DRIVER", description: "bullmq | inline | off; unset picks by REDIS_URL and NODE_ENV." },
33
+ { name: "JOBS_WORKER", scope: "worker", description: "\"1\" on the ONE process that consumes the queue and runs schedules." },
34
+ { name: "JOBS_QUEUE_PREFIX", description: "Optional key prefix, to share one Redis across environments." },
35
+ ],
36
+ } as const;