@12-apps/jobs 4.1.0 → 4.2.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 +53 -0
- package/package.json +1 -1
- package/src/core/module.ts +236 -0
- package/src/index.ts +15 -0
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.
|
|
3
|
+
"version": "4.2.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": {
|
|
@@ -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,
|