@nage-api/queue 1.0.0-beta.2

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/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # @nage-api/queue
2
+
3
+ Typed background jobs (PLAN.md §8, §25 P2).
4
+
5
+ The `Job` contract is retained from the legacy framework so migrating is a
6
+ driver swap rather than a rewrite of every consumer (§26). What changes is that
7
+ both ends now share a type:
8
+
9
+ <!-- docs-example: fragment -->
10
+
11
+ ```ts
12
+ import type { QueueService } from '@nage-api/queue';
13
+
14
+ // A `type`, not an `interface`: an interface has to say `extends QueueJobMap` to
15
+ // satisfy the constraint, and inheriting that index signature widens `keyof` to
16
+ // `string` — which silently stops the job *name* from being checked at all.
17
+ type Jobs = {
18
+ 'email.welcome': { userId: string };
19
+ 'report.build': { month: string };
20
+ };
21
+
22
+ declare const queue: QueueService<Jobs>;
23
+ declare const userId: string;
24
+ declare function sendWelcomeEmail(id: string, attempt: number): Promise<void>;
25
+
26
+ await queue.enqueue('email.welcome', { userId }); // name and payload both checked
27
+
28
+ queue.process('email.welcome', async ({ job, attempt }) => {
29
+ await sendWelcomeEmail(job.payload.userId, attempt);
30
+ });
31
+ ```
32
+
33
+ The legacy bus passed `any` in both directions, so a renamed field was a runtime
34
+ failure in a worker nobody was watching.
35
+
36
+ ## Wiring it up
37
+
38
+ The feature is off unless `queue.enabled` is `true`, and registering the module
39
+ publishes a publisher, not a worker: **nothing consumes until something calls
40
+ `start()`**, and the driver `forRoot` builds has no poll timer of its own. A
41
+ process that is meant to run jobs supplies a driver with an interval and starts
42
+ it.
43
+
44
+ ```ts
45
+ import { Injectable, Module, type OnApplicationBootstrap } from '@nestjs/common';
46
+ import {
47
+ MemoryJobLogStore,
48
+ MemoryQueueDriver,
49
+ NageQueueModule,
50
+ QueueService,
51
+ } from '@nage-api/queue';
52
+ import type { NodeEnvironment } from '@nage-api/contracts';
53
+
54
+ type Jobs = {
55
+ 'email.welcome': { userId: string };
56
+ };
57
+
58
+ declare const env: { NODE_ENV: NodeEnvironment };
59
+ declare function sendWelcomeEmail(userId: string): Promise<void>;
60
+
61
+ // One store, given to both: `forRoot` publishes the log store for readers, but a
62
+ // driver you construct yourself is never handed it, so nothing would write.
63
+ const jobLogs = new MemoryJobLogStore();
64
+
65
+ @Injectable()
66
+ export class EmailWorker implements OnApplicationBootstrap {
67
+ // The provider is the bare class, so the job map is a compile-time view of it.
68
+ constructor(private readonly queue: QueueService<Jobs>) {}
69
+
70
+ async onApplicationBootstrap(): Promise<void> {
71
+ // Register before starting: a job whose name has no handler is dead on
72
+ // arrival, not queued until one appears.
73
+ this.queue.process('email.welcome', async ({ job }) => {
74
+ await sendWelcomeEmail(job.payload.userId);
75
+ });
76
+
77
+ await this.queue.start();
78
+ }
79
+ }
80
+
81
+ @Module({
82
+ imports: [
83
+ NageQueueModule.forRoot({
84
+ queue: { enabled: true, jobLogs: true },
85
+ // Passed so the module can refuse a driver that loses jobs on restart.
86
+ environment: env.NODE_ENV,
87
+ logs: jobLogs,
88
+ driver: new MemoryQueueDriver({ concurrency: 4, pollIntervalMs: 250, logs: jobLogs }),
89
+ }),
90
+ ],
91
+ providers: [EmailWorker],
92
+ })
93
+ export class WorkerModule {}
94
+ ```
95
+
96
+ `onApplicationShutdown` drains whatever the module started, so `SIGTERM` does not
97
+ abandon a job that had already been taken.
98
+
99
+ ## What the driver guarantees
100
+
101
+ **Retries with jittered exponential backoff.** The jitter is the point: a batch
102
+ of jobs that failed together at the same instant will otherwise retry together,
103
+ and the dependency they were waiting on goes down again.
104
+
105
+ **A dead-letter state.** A job that exhausts its attempts becomes `dead` and
106
+ fires `onDead`, rather than vanishing or retrying forever. A job with no
107
+ registered handler is dead immediately — retrying would burn the attempts, and
108
+ dropping it silently would hide a deployment mistake.
109
+
110
+ **De-duplication by key**, while the first is still pending. That is what makes
111
+ an at-least-once queue tolerable for "send the welcome email".
112
+
113
+ **The correlation id travels with the job.** A job is usually the tail of a
114
+ request, and losing the id at the queue boundary is where a trace stops being
115
+ useful.
116
+
117
+ **Graceful drain on shutdown**, so a worker does not exit mid-job.
118
+
119
+ ## Job logs
120
+
121
+ Opt-in (`queue.jobLogs`). Each transition records state, attempt, duration and
122
+ the failure message — but never the payload, which may carry personal data and
123
+ would outlive the job by months.
124
+
125
+ ## Drivers
126
+
127
+ `MemoryQueueDriver` is a real implementation, not a stub: retries, delays,
128
+ de-duplication, concurrency and drain all behave the way the BullMQ adapter
129
+ must, which makes it the executable specification of the port. It is driven by
130
+ an explicit `tick()`/`drain()` as well as a timer, so tests advance it
131
+ deterministically instead of sleeping.
132
+
133
+ It is not durable, so the module **refuses to use it in production** — the
134
+ failure otherwise presents as "some emails were never sent", weeks later, with
135
+ nothing in the logs.
136
+
137
+ ## Not yet implemented
138
+
139
+ - The BullMQ driver itself (§27.5). `QueueDriver` is the seam; the application
140
+ constructs BullMQ and passes it in, so ioredis stays out of the install for
141
+ deployments that run no workers.
142
+ - Repeatable/cron jobs, priorities, and `JobContext.progress` reporting — the
143
+ hook exists and the memory driver ignores it.
144
+ - A queue-depth gauge wired to `@nage-api/observability`.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@nage-api/queue` — typed background jobs (PLAN.md §8, §25 P2).
3
+ *
4
+ * The `Job` contract is retained from the legacy framework so migrating is a
5
+ * driver swap rather than a rewrite of every consumer (§26); what changes is
6
+ * that both ends of the queue now share a type.
7
+ */
8
+ export type * from '@nage-api/contracts';
9
+ export { NageQueueModule, type NageQueueModuleOptions } from './queue.module.js';
10
+ export { QueueService } from './queue.service.js';
11
+ export { DEFAULT_RETRY_POLICY, MemoryJobLogStore, MemoryQueueDriver, backoffFor, type MemoryQueueDriverOptions, } from './memory.driver.js';
12
+ export { NAGE_JOB_LOGS, NAGE_QUEUE, NAGE_QUEUE_DRIVER } from './tokens.js';
13
+ export { systemClock, type Clock, type EnqueueOptions, type JobContext, type JobHandler, type JobHandlers, type JobLog, type JobLogStore, type JobState, type QueueDriver, type RetryPolicy, } from './ports.js';
14
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ /**
3
+ * `@nage-api/queue` — typed background jobs (PLAN.md §8, §25 P2).
4
+ *
5
+ * The `Job` contract is retained from the legacy framework so migrating is a
6
+ * driver swap rather than a rewrite of every consumer (§26); what changes is
7
+ * that both ends of the queue now share a type.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.systemClock = exports.NAGE_QUEUE_DRIVER = exports.NAGE_QUEUE = exports.NAGE_JOB_LOGS = exports.backoffFor = exports.MemoryQueueDriver = exports.MemoryJobLogStore = exports.DEFAULT_RETRY_POLICY = exports.QueueService = exports.NageQueueModule = void 0;
11
+ var queue_module_js_1 = require("./queue.module.js");
12
+ Object.defineProperty(exports, "NageQueueModule", { enumerable: true, get: function () { return queue_module_js_1.NageQueueModule; } });
13
+ var queue_service_js_1 = require("./queue.service.js");
14
+ Object.defineProperty(exports, "QueueService", { enumerable: true, get: function () { return queue_service_js_1.QueueService; } });
15
+ var memory_driver_js_1 = require("./memory.driver.js");
16
+ Object.defineProperty(exports, "DEFAULT_RETRY_POLICY", { enumerable: true, get: function () { return memory_driver_js_1.DEFAULT_RETRY_POLICY; } });
17
+ Object.defineProperty(exports, "MemoryJobLogStore", { enumerable: true, get: function () { return memory_driver_js_1.MemoryJobLogStore; } });
18
+ Object.defineProperty(exports, "MemoryQueueDriver", { enumerable: true, get: function () { return memory_driver_js_1.MemoryQueueDriver; } });
19
+ Object.defineProperty(exports, "backoffFor", { enumerable: true, get: function () { return memory_driver_js_1.backoffFor; } });
20
+ var tokens_js_1 = require("./tokens.js");
21
+ Object.defineProperty(exports, "NAGE_JOB_LOGS", { enumerable: true, get: function () { return tokens_js_1.NAGE_JOB_LOGS; } });
22
+ Object.defineProperty(exports, "NAGE_QUEUE", { enumerable: true, get: function () { return tokens_js_1.NAGE_QUEUE; } });
23
+ Object.defineProperty(exports, "NAGE_QUEUE_DRIVER", { enumerable: true, get: function () { return tokens_js_1.NAGE_QUEUE_DRIVER; } });
24
+ var ports_js_1 = require("./ports.js");
25
+ Object.defineProperty(exports, "systemClock", { enumerable: true, get: function () { return ports_js_1.systemClock; } });
26
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * An in-process queue: the default driver, and the one the tests use.
3
+ *
4
+ * It is a real implementation, not a stub — retries with exponential backoff
5
+ * and jitter, delayed jobs, de-duplication, a dead-letter state, bounded
6
+ * concurrency and graceful drain all behave the way the BullMQ adapter must.
7
+ * That is what makes it useful as the executable specification of the port.
8
+ *
9
+ * What it is not is durable. A restart loses every pending job, so the module
10
+ * refuses to use it in production — the failure mode otherwise is "some emails
11
+ * were never sent" discovered weeks later.
12
+ *
13
+ * The loop is driven by an explicit `tick()` as well as a timer, so a test can
14
+ * advance the queue deterministically rather than sleeping.
15
+ */
16
+ import type { QueueJob } from '@nage-api/contracts';
17
+ import type { Clock, EnqueueOptions, JobHandler, JobLog, JobLogStore, JobState, QueueDriver, RetryPolicy } from './ports.js';
18
+ export declare const DEFAULT_RETRY_POLICY: RetryPolicy;
19
+ export interface MemoryQueueDriverOptions {
20
+ /** Jobs run at once. */
21
+ readonly concurrency?: number;
22
+ /** Poll interval for delayed work. `0` leaves the queue tick-driven. */
23
+ readonly pollIntervalMs?: number;
24
+ readonly clock?: Clock;
25
+ readonly logs?: JobLogStore;
26
+ readonly defaultRetry?: Partial<RetryPolicy>;
27
+ /** Called when a job exhausts its attempts. */
28
+ readonly onDead?: (log: JobLog) => void;
29
+ }
30
+ export declare class MemoryQueueDriver implements QueueDriver {
31
+ #private;
32
+ readonly name = "memory";
33
+ constructor(options?: MemoryQueueDriverOptions);
34
+ enqueue<TName extends string, TPayload>(job: QueueJob<TName, TPayload>, options?: EnqueueOptions): Promise<string>;
35
+ process<TName extends string, TPayload>(name: TName, handler: JobHandler<TName, TPayload>): void;
36
+ start(): Promise<void>;
37
+ stop(): Promise<void>;
38
+ /**
39
+ * Run whatever is due now.
40
+ *
41
+ * @returns how many jobs were executed
42
+ */
43
+ tick(): Promise<number>;
44
+ /** Run until nothing is due; the deterministic way to drive the queue. */
45
+ drain(maxRounds?: number): Promise<number>;
46
+ depth(name?: string): Promise<number>;
47
+ /** Every job the driver still holds. Tests and diagnostics only. */
48
+ get jobs(): readonly {
49
+ id: string;
50
+ name: string;
51
+ state: JobState;
52
+ attempt: number;
53
+ }[];
54
+ }
55
+ /**
56
+ * Exponential backoff with jitter, bounded by `maxBackoffMs`.
57
+ *
58
+ * The jitter is the point. A batch of jobs that failed together at the same
59
+ * instant will otherwise retry together, and the recovering dependency they
60
+ * were waiting on goes down again.
61
+ */
62
+ export declare function backoffFor(attempt: number, policy: RetryPolicy): number;
63
+ /** In-memory job logs; a real deployment records them in its database. */
64
+ export declare class MemoryJobLogStore implements JobLogStore {
65
+ #private;
66
+ record(log: JobLog): Promise<void>;
67
+ find(id: string): Promise<JobLog | undefined>;
68
+ list(filter?: {
69
+ name?: string;
70
+ state?: JobState;
71
+ }): Promise<readonly JobLog[]>;
72
+ /** Every recorded transition, in order. Tests only. */
73
+ get history(): readonly JobLog[];
74
+ }
75
+ //# sourceMappingURL=memory.driver.d.ts.map
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+ /**
3
+ * An in-process queue: the default driver, and the one the tests use.
4
+ *
5
+ * It is a real implementation, not a stub — retries with exponential backoff
6
+ * and jitter, delayed jobs, de-duplication, a dead-letter state, bounded
7
+ * concurrency and graceful drain all behave the way the BullMQ adapter must.
8
+ * That is what makes it useful as the executable specification of the port.
9
+ *
10
+ * What it is not is durable. A restart loses every pending job, so the module
11
+ * refuses to use it in production — the failure mode otherwise is "some emails
12
+ * were never sent" discovered weeks later.
13
+ *
14
+ * The loop is driven by an explicit `tick()` as well as a timer, so a test can
15
+ * advance the queue deterministically rather than sleeping.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.MemoryJobLogStore = exports.MemoryQueueDriver = exports.DEFAULT_RETRY_POLICY = void 0;
19
+ exports.backoffFor = backoffFor;
20
+ const core_1 = require("@nage-api/core");
21
+ exports.DEFAULT_RETRY_POLICY = {
22
+ maxAttempts: 3,
23
+ backoffMs: 1000,
24
+ maxBackoffMs: 60_000,
25
+ jitter: 0.2,
26
+ };
27
+ class MemoryQueueDriver {
28
+ name = 'memory';
29
+ #jobs = new Map();
30
+ #handlers = new Map();
31
+ #byKey = new Map();
32
+ #concurrency;
33
+ #pollIntervalMs;
34
+ #clock;
35
+ #logs;
36
+ #defaultRetry;
37
+ #onDead;
38
+ /**
39
+ * Runs a poll tick started but has not finished.
40
+ *
41
+ * The timer cannot await its own tick, so without this handle `stop()` has
42
+ * nothing to wait on: an executing job is `active` rather than `pending`, so
43
+ * draining walks straight past it and shutdown abandons work it had taken.
44
+ */
45
+ #inFlight = new Set();
46
+ #running = false;
47
+ #active = 0;
48
+ #timer;
49
+ constructor(options = {}) {
50
+ // Clamped rather than trusted: a configured `0` used to be absorbed by the
51
+ // batch size, and the alternative now — a capacity of zero — would wedge
52
+ // the queue silently, which is the worse of the two failures.
53
+ this.#concurrency = Math.max(1, options.concurrency ?? 1);
54
+ this.#pollIntervalMs = options.pollIntervalMs ?? 0;
55
+ this.#clock = options.clock ?? { now: () => Date.now() };
56
+ this.#logs = options.logs;
57
+ this.#defaultRetry = { ...exports.DEFAULT_RETRY_POLICY, ...options.defaultRetry };
58
+ this.#onDead = options.onDead;
59
+ }
60
+ async enqueue(job, options = {}) {
61
+ await Promise.resolve();
62
+ if (options.key !== undefined) {
63
+ const existing = this.#byKey.get(options.key);
64
+ const pending = existing === undefined ? undefined : this.#jobs.get(existing);
65
+ // De-duplication only applies while the first is still waiting: once it
66
+ // has run, the same key should be able to schedule new work.
67
+ if (pending !== undefined && (pending.state === 'pending' || pending.state === 'active')) {
68
+ return pending.id;
69
+ }
70
+ }
71
+ const now = this.#clock.now();
72
+ const id = (0, core_1.randomId)();
73
+ const queued = {
74
+ id,
75
+ job,
76
+ retry: { ...this.#defaultRetry, ...options.retry },
77
+ key: options.key,
78
+ enqueuedAt: now,
79
+ runAt: now + (options.delayMs ?? 0),
80
+ attempt: 0,
81
+ state: 'pending',
82
+ };
83
+ this.#jobs.set(id, queued);
84
+ if (options.key !== undefined)
85
+ this.#byKey.set(options.key, id);
86
+ await this.#log(queued, 'pending');
87
+ return id;
88
+ }
89
+ process(name, handler) {
90
+ this.#handlers.set(name, handler);
91
+ }
92
+ async start() {
93
+ await Promise.resolve();
94
+ if (this.#running)
95
+ return;
96
+ this.#running = true;
97
+ if (this.#pollIntervalMs > 0) {
98
+ this.#timer = setInterval(() => void this.tick(), this.#pollIntervalMs);
99
+ // A worker's poll timer must not keep a CLI or a test runner alive.
100
+ this.#timer.unref();
101
+ }
102
+ }
103
+ async stop() {
104
+ this.#running = false;
105
+ if (this.#timer !== undefined) {
106
+ clearInterval(this.#timer);
107
+ this.#timer = undefined;
108
+ }
109
+ // Let in-flight work finish rather than abandoning it half-done. Draining
110
+ // alone does not do that: a job a poll tick already took is `active`, so
111
+ // `#due()` excludes it and shutdown would return mid-handler.
112
+ await Promise.all([...this.#inFlight]);
113
+ await this.drain();
114
+ }
115
+ /**
116
+ * Run whatever is due now.
117
+ *
118
+ * @returns how many jobs were executed
119
+ */
120
+ async tick() {
121
+ const due = this.#due();
122
+ if (due.length === 0)
123
+ return 0;
124
+ // Free slots only. Admitting one job regardless — which is what a
125
+ // `Math.max(1, …)` batch size did — breaks the cap as soon as ticks
126
+ // overlap, and under the poll timer they always do: the timer fires again
127
+ // while the previous tick's handlers are still running, so every poll added
128
+ // another job on top of a queue that was already at capacity.
129
+ const capacity = this.#concurrency - this.#active;
130
+ if (capacity <= 0)
131
+ return 0;
132
+ const batch = due.slice(0, capacity);
133
+ await Promise.all(batch.map((queued) => this.#track(this.#run(queued))));
134
+ return batch.length;
135
+ }
136
+ /** Run until nothing is due; the deterministic way to drive the queue. */
137
+ async drain(maxRounds = 1000) {
138
+ let executed = 0;
139
+ for (let round = 0; round < maxRounds; round += 1) {
140
+ const ran = await this.tick();
141
+ if (ran === 0)
142
+ break;
143
+ executed += ran;
144
+ }
145
+ return executed;
146
+ }
147
+ async depth(name) {
148
+ await Promise.resolve();
149
+ return [...this.#jobs.values()].filter((queued) => queued.state === 'pending' && (name === undefined || queued.job.name === name)).length;
150
+ }
151
+ /** Every job the driver still holds. Tests and diagnostics only. */
152
+ get jobs() {
153
+ return [...this.#jobs.values()].map((queued) => ({
154
+ id: queued.id,
155
+ name: queued.job.name,
156
+ state: queued.state,
157
+ attempt: queued.attempt,
158
+ }));
159
+ }
160
+ /** Hold a run so `stop()` can wait for it, and release it when it settles. */
161
+ #track(work) {
162
+ this.#inFlight.add(work);
163
+ return work.finally(() => {
164
+ this.#inFlight.delete(work);
165
+ });
166
+ }
167
+ #due() {
168
+ const now = this.#clock.now();
169
+ return [...this.#jobs.values()]
170
+ .filter((queued) => queued.state === 'pending' && queued.runAt <= now)
171
+ .sort((left, right) => left.runAt - right.runAt);
172
+ }
173
+ async #run(queued) {
174
+ const handler = this.#handlers.get(queued.job.name);
175
+ if (handler === undefined) {
176
+ // A job nobody handles is dead on arrival: retrying would just burn the
177
+ // attempts, and dropping it silently would hide a deployment mistake.
178
+ queued.state = 'dead';
179
+ // One record, recorded and alerted on. Building it twice gave the log and
180
+ // the alert different `error` text for the same job, so an operator
181
+ // grepping the log for what the page said found nothing.
182
+ const log = await this.#buildLog(queued, 'dead', 'no handler is registered for this job name');
183
+ await this.#logs?.record(log);
184
+ this.#onDead?.(log);
185
+ return;
186
+ }
187
+ queued.state = 'active';
188
+ queued.attempt += 1;
189
+ this.#active += 1;
190
+ const startedAt = this.#clock.now();
191
+ const context = {
192
+ job: queued.job,
193
+ attempt: queued.attempt,
194
+ maxAttempts: queued.retry.maxAttempts,
195
+ progress: () => undefined,
196
+ };
197
+ try {
198
+ await handler(context);
199
+ queued.state = 'completed';
200
+ await this.#log(queued, 'completed', undefined, startedAt);
201
+ }
202
+ catch (error) {
203
+ const message = error instanceof Error ? error.message : String(error);
204
+ if (queued.attempt >= queued.retry.maxAttempts) {
205
+ queued.state = 'dead';
206
+ const log = await this.#buildLog(queued, 'dead', message, startedAt);
207
+ await this.#logs?.record(log);
208
+ this.#onDead?.(log);
209
+ }
210
+ else {
211
+ queued.state = 'pending';
212
+ queued.runAt = this.#clock.now() + backoffFor(queued.attempt, queued.retry);
213
+ await this.#log(queued, 'failed', message, startedAt);
214
+ }
215
+ }
216
+ finally {
217
+ this.#active -= 1;
218
+ }
219
+ }
220
+ async #log(queued, state, error, startedAt) {
221
+ if (this.#logs === undefined)
222
+ return;
223
+ await this.#logs.record(await this.#buildLog(queued, state, error, startedAt));
224
+ }
225
+ async #buildLog(queued, state, error, startedAt) {
226
+ await Promise.resolve();
227
+ const finishedAt = this.#clock.now();
228
+ return {
229
+ id: queued.id,
230
+ name: queued.job.name,
231
+ state,
232
+ attempt: queued.attempt,
233
+ enqueuedAt: queued.enqueuedAt,
234
+ ...(startedAt === undefined
235
+ ? {}
236
+ : { startedAt, finishedAt, durationMs: finishedAt - startedAt }),
237
+ ...(error === undefined ? {} : { error }),
238
+ ...(queued.job.requestId === undefined ? {} : { requestId: queued.job.requestId }),
239
+ };
240
+ }
241
+ }
242
+ exports.MemoryQueueDriver = MemoryQueueDriver;
243
+ /**
244
+ * Exponential backoff with jitter, bounded by `maxBackoffMs`.
245
+ *
246
+ * The jitter is the point. A batch of jobs that failed together at the same
247
+ * instant will otherwise retry together, and the recovering dependency they
248
+ * were waiting on goes down again.
249
+ */
250
+ function backoffFor(attempt, policy) {
251
+ const exponential = Math.min(policy.backoffMs * 2 ** (attempt - 1), policy.maxBackoffMs);
252
+ if (policy.jitter <= 0)
253
+ return exponential;
254
+ const spread = Math.floor(exponential * policy.jitter);
255
+ return exponential - spread + (0, core_1.randomInt)(0, spread * 2);
256
+ }
257
+ /** In-memory job logs; a real deployment records them in its database. */
258
+ class MemoryJobLogStore {
259
+ #logs = new Map();
260
+ #history = [];
261
+ async record(log) {
262
+ await Promise.resolve();
263
+ this.#logs.set(log.id, log);
264
+ this.#history.push(log);
265
+ }
266
+ async find(id) {
267
+ await Promise.resolve();
268
+ return this.#logs.get(id);
269
+ }
270
+ async list(filter = {}) {
271
+ await Promise.resolve();
272
+ return [...this.#logs.values()].filter((log) => (filter.name === undefined || log.name === filter.name) &&
273
+ (filter.state === undefined || log.state === filter.state));
274
+ }
275
+ /** Every recorded transition, in order. Tests only. */
276
+ get history() {
277
+ return this.#history;
278
+ }
279
+ }
280
+ exports.MemoryJobLogStore = MemoryJobLogStore;
281
+ //# sourceMappingURL=memory.driver.js.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Background jobs (PLAN.md §8, §25 P2, §26 "keep the `Job` contract").
3
+ *
4
+ * `QueueJob<TName, TPayload>` already lives in `@nage-api/contracts`, so a
5
+ * publisher and a consumer share one type and a payload cannot drift between
6
+ * them. This file adds what a driver must implement and what a run produces.
7
+ *
8
+ * The legacy framework's bespoke Redis message bus is replaced by a port with a
9
+ * BullMQ adapter (§27.5), but the shape of a job is retained deliberately: the
10
+ * migration should be a driver swap, not a rewrite of every consumer.
11
+ */
12
+ import type { QueueJob, QueueJobMap } from '@nage-api/contracts';
13
+ /** What a handler is given. The payload is typed by the job map. */
14
+ export interface JobContext<TName extends string, TPayload> {
15
+ readonly job: QueueJob<TName, TPayload>;
16
+ /** 1 on the first run; incremented on every retry. */
17
+ readonly attempt: number;
18
+ /** Attempts this job gets in total, so a handler can behave on the last one. */
19
+ readonly maxAttempts: number;
20
+ /** Emit progress; drivers that cannot report it ignore this. */
21
+ progress(percent: number): void;
22
+ }
23
+ export type JobHandler<TName extends string, TPayload> = (context: JobContext<TName, TPayload>) => Promise<void>;
24
+ /** How a job behaves when it fails. */
25
+ export interface RetryPolicy {
26
+ /** Total attempts, including the first. `1` means no retry. */
27
+ readonly maxAttempts: number;
28
+ /** Delay before the first retry; doubled on each subsequent one. */
29
+ readonly backoffMs: number;
30
+ /** Ceiling on the backoff, so an exponential curve stays bounded. */
31
+ readonly maxBackoffMs: number;
32
+ /**
33
+ * Jitter, as a fraction of the delay.
34
+ *
35
+ * Without it, a batch of jobs that failed together retries together — the
36
+ * thundering herd that keeps a recovering dependency down.
37
+ */
38
+ readonly jitter: number;
39
+ }
40
+ export interface EnqueueOptions {
41
+ /** Run no earlier than this many milliseconds from now. */
42
+ readonly delayMs?: number;
43
+ readonly retry?: Partial<RetryPolicy>;
44
+ /**
45
+ * De-duplication key.
46
+ *
47
+ * A job enqueued twice with the same key runs once while the first is still
48
+ * pending — which is what makes an at-least-once queue tolerable for
49
+ * "send the welcome email" work.
50
+ */
51
+ readonly key?: string;
52
+ }
53
+ export type JobState = 'pending' | 'active' | 'completed' | 'failed' | 'dead';
54
+ /** A durable record of one job and its outcome (§8: "job logs (opt-in)"). */
55
+ export interface JobLog {
56
+ readonly id: string;
57
+ readonly name: string;
58
+ readonly state: JobState;
59
+ readonly attempt: number;
60
+ readonly enqueuedAt: number;
61
+ readonly startedAt?: number;
62
+ readonly finishedAt?: number;
63
+ readonly durationMs?: number;
64
+ /** Failure message. Never the payload, which may carry personal data. */
65
+ readonly error?: string;
66
+ readonly requestId?: string;
67
+ }
68
+ export interface JobLogStore {
69
+ record(log: JobLog): Promise<void>;
70
+ find(id: string): Promise<JobLog | undefined>;
71
+ list(filter?: {
72
+ name?: string;
73
+ state?: JobState;
74
+ }): Promise<readonly JobLog[]>;
75
+ }
76
+ /** A queue backend. */
77
+ export interface QueueDriver {
78
+ readonly name: string;
79
+ enqueue<TName extends string, TPayload>(job: QueueJob<TName, TPayload>, options: EnqueueOptions): Promise<string>;
80
+ /** Register the handler for one job name. */
81
+ process<TName extends string, TPayload>(name: TName, handler: JobHandler<TName, TPayload>): void;
82
+ /** Begin consuming. Idempotent. */
83
+ start(): Promise<void>;
84
+ /** Stop consuming and let in-flight work finish. */
85
+ stop(): Promise<void>;
86
+ /** Jobs waiting, by name. */
87
+ depth(name?: string): Promise<number>;
88
+ }
89
+ /** Injected so delays and backoff are testable without waiting. */
90
+ export interface Clock {
91
+ now(): number;
92
+ }
93
+ export declare const systemClock: Clock;
94
+ /** Convenience alias: the handler map an application registers. */
95
+ export type JobHandlers<TMap extends QueueJobMap> = {
96
+ [TName in keyof TMap & string]?: JobHandler<TName, TMap[TName]>;
97
+ };
98
+ //# sourceMappingURL=ports.d.ts.map
package/dist/ports.js ADDED
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ /**
3
+ * Background jobs (PLAN.md §8, §25 P2, §26 "keep the `Job` contract").
4
+ *
5
+ * `QueueJob<TName, TPayload>` already lives in `@nage-api/contracts`, so a
6
+ * publisher and a consumer share one type and a payload cannot drift between
7
+ * them. This file adds what a driver must implement and what a run produces.
8
+ *
9
+ * The legacy framework's bespoke Redis message bus is replaced by a port with a
10
+ * BullMQ adapter (§27.5), but the shape of a job is retained deliberately: the
11
+ * migration should be a driver swap, not a rewrite of every consumer.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.systemClock = void 0;
15
+ exports.systemClock = { now: () => Date.now() };
16
+ //# sourceMappingURL=ports.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `NageQueueModule.forRoot(config)` (PLAN.md §11.1 item 6).
3
+ *
4
+ * The in-memory driver is the default and is refused in production: it loses
5
+ * every pending job on restart, and that failure presents as "some emails were
6
+ * never sent", weeks later, with nothing in the logs.
7
+ *
8
+ * BullMQ is the intended production driver (§27.5). It is not bundled — the
9
+ * application constructs it and passes it in, so ioredis stays out of the
10
+ * install for deployments that do not run workers.
11
+ */
12
+ import { type DynamicModule, type OnApplicationShutdown } from '@nestjs/common';
13
+ import type { QueueConfig } from '@nage-api/contracts';
14
+ import type { Clock, JobLogStore, QueueDriver } from './ports.js';
15
+ export interface NageQueueModuleOptions {
16
+ readonly queue?: QueueConfig;
17
+ readonly environment?: string;
18
+ /** A production driver — BullMQ, say. */
19
+ readonly driver?: QueueDriver;
20
+ /** Where job logs go; in-memory when `queue.jobLogs` is on and none is given. */
21
+ readonly logs?: JobLogStore;
22
+ readonly clock?: Clock;
23
+ }
24
+ export declare class NageQueueModule implements OnApplicationShutdown {
25
+ static forRoot(options?: NageQueueModuleOptions): DynamicModule;
26
+ onApplicationShutdown(): Promise<void>;
27
+ private static buildDriver;
28
+ }
29
+ //# sourceMappingURL=queue.module.d.ts.map
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ /**
3
+ * `NageQueueModule.forRoot(config)` (PLAN.md §11.1 item 6).
4
+ *
5
+ * The in-memory driver is the default and is refused in production: it loses
6
+ * every pending job on restart, and that failure presents as "some emails were
7
+ * never sent", weeks later, with nothing in the logs.
8
+ *
9
+ * BullMQ is the intended production driver (§27.5). It is not bundled — the
10
+ * application constructs it and passes it in, so ioredis stays out of the
11
+ * install for deployments that do not run workers.
12
+ */
13
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
14
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
15
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
16
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
17
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
18
+ };
19
+ var NageQueueModule_1;
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.NageQueueModule = void 0;
22
+ const common_1 = require("@nestjs/common");
23
+ const core_1 = require("@nage-api/core");
24
+ const memory_driver_js_1 = require("./memory.driver.js");
25
+ const queue_service_js_1 = require("./queue.service.js");
26
+ const tokens_js_1 = require("./tokens.js");
27
+ /** Drivers started by `forRoot`, so shutdown can drain them. */
28
+ const started = [];
29
+ let NageQueueModule = NageQueueModule_1 = class NageQueueModule {
30
+ static forRoot(options = {}) {
31
+ const config = options.queue ?? {};
32
+ if (config.enabled !== true) {
33
+ return { module: NageQueueModule_1, providers: [], exports: [] };
34
+ }
35
+ const logs = config.jobLogs === true ? (options.logs ?? new memory_driver_js_1.MemoryJobLogStore()) : undefined;
36
+ const driver = options.driver ?? NageQueueModule_1.buildDriver(config, options, logs);
37
+ const service = new queue_service_js_1.QueueService(driver);
38
+ started.push(driver);
39
+ const providers = [
40
+ { provide: tokens_js_1.NAGE_QUEUE_DRIVER, useValue: driver },
41
+ { provide: tokens_js_1.NAGE_QUEUE, useValue: service },
42
+ { provide: queue_service_js_1.QueueService, useValue: service },
43
+ ...(logs === undefined ? [] : [{ provide: tokens_js_1.NAGE_JOB_LOGS, useValue: logs }]),
44
+ ];
45
+ return {
46
+ module: NageQueueModule_1,
47
+ providers,
48
+ exports: [
49
+ tokens_js_1.NAGE_QUEUE,
50
+ tokens_js_1.NAGE_QUEUE_DRIVER,
51
+ queue_service_js_1.QueueService,
52
+ ...(logs === undefined ? [] : [tokens_js_1.NAGE_JOB_LOGS]),
53
+ ],
54
+ };
55
+ }
56
+ async onApplicationShutdown() {
57
+ // Drain rather than drop: a worker that exits mid-job leaves work that was
58
+ // taken but never finished.
59
+ for (const driver of started.splice(0))
60
+ await driver.stop();
61
+ }
62
+ static buildDriver(config, options, logs) {
63
+ const deployed = options.environment === 'production' || options.environment === 'staging';
64
+ if (deployed) {
65
+ throw new core_1.ConfigurationError({
66
+ detail: 'The in-memory queue driver loses every pending job on restart; pass a durable driver to NageQueueModule.forRoot({ driver }) in a deployed environment',
67
+ meta: { setting: 'queue.driver', environment: options.environment },
68
+ });
69
+ }
70
+ // `queue.driver` only admits 'bullmq' today, and BullMQ is not bundled —
71
+ // the application constructs it and passes it in, so ioredis stays out of
72
+ // the install for deployments that run no workers.
73
+ return new memory_driver_js_1.MemoryQueueDriver({
74
+ ...(config.concurrency === undefined ? {} : { concurrency: config.concurrency }),
75
+ ...(logs === undefined ? {} : { logs }),
76
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
77
+ });
78
+ }
79
+ };
80
+ exports.NageQueueModule = NageQueueModule;
81
+ exports.NageQueueModule = NageQueueModule = NageQueueModule_1 = __decorate([
82
+ (0, common_1.Module)({})
83
+ ], NageQueueModule);
84
+ //# sourceMappingURL=queue.module.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The typed publisher application code uses (PLAN.md §8).
3
+ *
4
+ * `QueueService<TMap>` is parameterised by the application's job map, so
5
+ * `enqueue('email.welcome', { userId })` is checked against the payload the
6
+ * consumer will receive. The legacy bus passed `any` in both directions; a
7
+ * renamed field was a runtime failure in a worker nobody was watching.
8
+ *
9
+ * The correlation id travels with the job. A job is usually the tail of a
10
+ * request, and losing the id at the queue boundary is where a trace stops
11
+ * being useful.
12
+ */
13
+ import type { QueueJobMap } from '@nage-api/contracts';
14
+ import type { EnqueueOptions, JobHandler, QueueDriver } from './ports.js';
15
+ export declare class QueueService<TMap extends QueueJobMap = QueueJobMap> {
16
+ #private;
17
+ constructor(driver: QueueDriver);
18
+ /** Publish a job. Returns the driver's id for it. */
19
+ enqueue<TName extends keyof TMap & string>(name: TName, payload: TMap[TName], options?: EnqueueOptions): Promise<string>;
20
+ /** Register the consumer for one job name. */
21
+ process<TName extends keyof TMap & string>(name: TName, handler: JobHandler<TName, TMap[TName]>): void;
22
+ start(): Promise<void>;
23
+ stop(): Promise<void>;
24
+ /** Jobs waiting; wire it to a gauge so a backlog is visible. */
25
+ depth(name?: keyof TMap & string): Promise<number>;
26
+ get driver(): QueueDriver;
27
+ }
28
+ //# sourceMappingURL=queue.service.d.ts.map
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ /**
3
+ * The typed publisher application code uses (PLAN.md §8).
4
+ *
5
+ * `QueueService<TMap>` is parameterised by the application's job map, so
6
+ * `enqueue('email.welcome', { userId })` is checked against the payload the
7
+ * consumer will receive. The legacy bus passed `any` in both directions; a
8
+ * renamed field was a runtime failure in a worker nobody was watching.
9
+ *
10
+ * The correlation id travels with the job. A job is usually the tail of a
11
+ * request, and losing the id at the queue boundary is where a trace stops
12
+ * being useful.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.QueueService = void 0;
16
+ const core_1 = require("@nage-api/core");
17
+ class QueueService {
18
+ #driver;
19
+ constructor(driver) {
20
+ this.#driver = driver;
21
+ }
22
+ /** Publish a job. Returns the driver's id for it. */
23
+ async enqueue(name, payload, options = {}) {
24
+ const requestId = (0, core_1.getActiveContext)()?.requestId;
25
+ const job = {
26
+ name,
27
+ payload,
28
+ ...(requestId === undefined ? {} : { requestId }),
29
+ ...(options.delayMs === undefined ? {} : { scheduledAt: Date.now() + options.delayMs }),
30
+ };
31
+ return this.#driver.enqueue(job, options);
32
+ }
33
+ /** Register the consumer for one job name. */
34
+ process(name, handler) {
35
+ this.#driver.process(name, handler);
36
+ }
37
+ async start() {
38
+ await this.#driver.start();
39
+ }
40
+ async stop() {
41
+ await this.#driver.stop();
42
+ }
43
+ /** Jobs waiting; wire it to a gauge so a backlog is visible. */
44
+ async depth(name) {
45
+ return this.#driver.depth(name);
46
+ }
47
+ get driver() {
48
+ return this.#driver;
49
+ }
50
+ }
51
+ exports.QueueService = QueueService;
52
+ //# sourceMappingURL=queue.service.js.map
@@ -0,0 +1,8 @@
1
+ /** DI tokens for the queue (PLAN.md §7.3). */
2
+ import { type Token } from '@nage-api/core';
3
+ import type { JobLogStore, QueueDriver } from './ports.js';
4
+ import type { QueueService } from './queue.service.js';
5
+ export declare const NAGE_QUEUE: Token<QueueService>;
6
+ export declare const NAGE_QUEUE_DRIVER: Token<QueueDriver>;
7
+ export declare const NAGE_JOB_LOGS: Token<JobLogStore>;
8
+ //# sourceMappingURL=tokens.d.ts.map
package/dist/tokens.js ADDED
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ /** DI tokens for the queue (PLAN.md §7.3). */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.NAGE_JOB_LOGS = exports.NAGE_QUEUE_DRIVER = exports.NAGE_QUEUE = void 0;
5
+ const core_1 = require("@nage-api/core");
6
+ exports.NAGE_QUEUE = (0, core_1.createToken)('NAGE_QUEUE');
7
+ exports.NAGE_QUEUE_DRIVER = (0, core_1.createToken)('NAGE_QUEUE_DRIVER');
8
+ exports.NAGE_JOB_LOGS = (0, core_1.createToken)('NAGE_JOB_LOGS');
9
+ //# sourceMappingURL=tokens.js.map
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@nage-api/queue",
3
+ "version": "1.0.0-beta.2",
4
+ "description": "Background jobs for @nage-api — typed job contracts, retries, job logs",
5
+ "license": "Apache-2.0",
6
+ "type": "commonjs",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "!dist/.tsbuildinfo",
20
+ "!dist/**/*.map",
21
+ "README.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "@nage-api/contracts": "1.0.0-beta.2",
28
+ "@nage-api/core": "1.0.0-beta.2"
29
+ },
30
+ "peerDependencies": {
31
+ "@nestjs/common": "^11.0.0",
32
+ "@nestjs/core": "^11.0.0",
33
+ "reflect-metadata": "^0.2.0"
34
+ },
35
+ "devDependencies": {
36
+ "@nestjs/common": "11.1.29",
37
+ "@nestjs/core": "11.1.29",
38
+ "@nestjs/testing": "11.1.29",
39
+ "@swc/core": "1.15.47",
40
+ "@types/node": "22.20.1",
41
+ "@vitest/coverage-v8": "4.1.10",
42
+ "reflect-metadata": "0.2.2",
43
+ "rimraf": "6.1.3",
44
+ "rxjs": "7.8.2",
45
+ "typescript": "5.9.3",
46
+ "unplugin-swc": "1.5.11",
47
+ "vitest": "4.1.10",
48
+ "@nage-api/testing": "1.0.0-beta.2"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.0.0"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -b tsconfig.build.json",
55
+ "clean": "rimraf dist .turbo",
56
+ "typecheck": "tsc -p tsconfig.json --noEmit",
57
+ "test": "vitest run"
58
+ }
59
+ }