@monque/tsed 0.1.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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @Worker method decorator
3
+ *
4
+ * Registers a method as a job handler. The method will be called when a job
5
+ * with the matching name is picked up for processing.
6
+ *
7
+ * @param name - Job name (combined with controller namespace if present).
8
+ * @param options - Worker configuration options.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * @WorkerController("notifications")
13
+ * export class NotificationWorkers {
14
+ * @Worker("push", { concurrency: 10 })
15
+ * async sendPush(job: Job<PushPayload>) {
16
+ * await pushService.send(job.data);
17
+ * }
18
+ * }
19
+ * ```
20
+ */
21
+ import { Store } from '@tsed/core';
22
+
23
+ import { MONQUE } from '@/constants';
24
+
25
+ import type { WorkerDecoratorOptions, WorkerMetadata, WorkerStore } from './types.js';
26
+
27
+ /**
28
+ * Method decorator that registers a method as a job handler.
29
+ *
30
+ * @param name - The job name (will be prefixed with controller namespace if present)
31
+ * @param options - Optional worker configuration (concurrency, replace, etc.)
32
+ */
33
+ export function Worker(name: string, options?: WorkerDecoratorOptions): MethodDecorator {
34
+ return <T>(
35
+ target: object,
36
+ propertyKey: string | symbol,
37
+ _descriptor: TypedPropertyDescriptor<T>,
38
+ ): void => {
39
+ const methodName = String(propertyKey);
40
+
41
+ const workerMetadata: WorkerMetadata = {
42
+ name,
43
+ method: methodName,
44
+ opts: options || {},
45
+ };
46
+
47
+ // Get the class constructor (target is the prototype for instance methods)
48
+ const targetConstructor = target.constructor;
49
+ const store = Store.from(targetConstructor);
50
+
51
+ // Get or initialize the MONQUE store
52
+ const existing = store.get<Partial<WorkerStore>>(MONQUE) || {
53
+ type: 'controller',
54
+ workers: [],
55
+ cronJobs: [],
56
+ };
57
+
58
+ // Add this worker to the list
59
+ const workers = [...(existing.workers || []), workerMetadata];
60
+
61
+ store.set(MONQUE, {
62
+ ...existing,
63
+ workers,
64
+ });
65
+ };
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { type MonqueTsedConfig, validateDatabaseConfig } from './config';
2
+ export { MONQUE, type ProviderType, ProviderTypes } from './constants';
3
+ export type {
4
+ CronDecoratorOptions,
5
+ CronMetadata,
6
+ WorkerDecoratorOptions,
7
+ WorkerMetadata,
8
+ WorkerStore,
9
+ } from './decorators';
10
+ export { Cron, Worker, WorkerController } from './decorators';
11
+ export { MonqueModule } from './monque-module.js';
12
+ export { MonqueService } from './services';
13
+ export {
14
+ buildJobName,
15
+ type CollectedWorkerMetadata,
16
+ collectWorkerMetadata,
17
+ getWorkerToken,
18
+ type InjectorFn,
19
+ resolveDatabase,
20
+ } from './utils';
@@ -0,0 +1,199 @@
1
+ /**
2
+ * MonqueModule - Main Integration Module
3
+ *
4
+ * Orchestrates the integration between Monque and Ts.ED.
5
+ * Handles lifecycle hooks, configuration resolution, and worker registration.
6
+ */
7
+
8
+ import {
9
+ type Job,
10
+ Monque,
11
+ type MonqueOptions,
12
+ type ScheduleOptions,
13
+ type WorkerOptions,
14
+ } from '@monque/core';
15
+ import {
16
+ Configuration,
17
+ DIContext,
18
+ Inject,
19
+ InjectorService,
20
+ LOGGER,
21
+ Module,
22
+ type OnDestroy,
23
+ type OnInit,
24
+ ProviderScope,
25
+ runInContext,
26
+ type TokenProvider,
27
+ } from '@tsed/di';
28
+
29
+ import { type MonqueTsedConfig, validateDatabaseConfig } from '@/config';
30
+ import { ProviderTypes } from '@/constants';
31
+ import { MonqueService } from '@/services';
32
+ import { collectWorkerMetadata, resolveDatabase } from '@/utils';
33
+
34
+ @Module({
35
+ imports: [MonqueService],
36
+ })
37
+ export class MonqueModule implements OnInit, OnDestroy {
38
+ protected injector: InjectorService;
39
+ protected monqueService: MonqueService;
40
+ protected logger: LOGGER;
41
+ protected monqueConfig: MonqueTsedConfig;
42
+
43
+ protected monque: Monque | null = null;
44
+
45
+ constructor(
46
+ @Inject(InjectorService) injector: InjectorService,
47
+ @Inject(MonqueService) monqueService: MonqueService,
48
+ @Inject(LOGGER) logger: LOGGER,
49
+ @Inject(Configuration) configuration: Configuration,
50
+ ) {
51
+ this.injector = injector;
52
+ this.monqueService = monqueService;
53
+ this.logger = logger;
54
+ this.monqueConfig = configuration.get<MonqueTsedConfig>('monque') || {};
55
+ }
56
+
57
+ async $onInit(): Promise<void> {
58
+ const config = this.monqueConfig;
59
+
60
+ if (config?.enabled === false) {
61
+ this.logger.info('Monque integration is disabled');
62
+
63
+ return;
64
+ }
65
+
66
+ validateDatabaseConfig(config);
67
+
68
+ try {
69
+ const db = await resolveDatabase(config, (token) =>
70
+ this.injector.get(token as TokenProvider),
71
+ );
72
+
73
+ // We construct the options object carefully to match MonqueOptions
74
+ const { db: _db, ...restConfig } = config;
75
+ const options: MonqueOptions = restConfig;
76
+
77
+ this.monque = new Monque(db, options);
78
+ this.monqueService._setMonque(this.monque);
79
+
80
+ this.logger.info('Monque: Connecting to MongoDB...');
81
+ await this.monque.initialize();
82
+
83
+ await this.registerWorkers();
84
+
85
+ await this.monque.start();
86
+
87
+ this.logger.info('Monque: Started successfully');
88
+ } catch (error) {
89
+ this.logger.error({
90
+ event: 'MONQUE_INIT_ERROR',
91
+ message: 'Failed to initialize Monque',
92
+ error,
93
+ });
94
+
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ async $onDestroy(): Promise<void> {
100
+ if (this.monque) {
101
+ this.logger.info('Monque: Stopping...');
102
+
103
+ await this.monque.stop();
104
+
105
+ this.logger.info('Monque: Stopped');
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Discover and register all workers from @WorkerController providers
111
+ */
112
+ protected async registerWorkers(): Promise<void> {
113
+ if (!this.monque) {
114
+ throw new Error('Monque instance not initialized');
115
+ }
116
+
117
+ const monque = this.monque;
118
+ const workerControllers = this.injector.getProviders(ProviderTypes.WORKER_CONTROLLER);
119
+ const registeredJobs = new Set<string>();
120
+
121
+ this.logger.info(`Monque: Found ${workerControllers.length} worker controllers`);
122
+
123
+ for (const provider of workerControllers) {
124
+ const useClass = provider.useClass;
125
+ const workers = collectWorkerMetadata(useClass);
126
+ // Try to resolve singleton instance immediately
127
+ const instance = this.injector.get(provider.token);
128
+
129
+ if (!instance && provider.scope !== ProviderScope.REQUEST) {
130
+ this.logger.warn(
131
+ `Monque: Could not resolve instance for controller ${provider.name}. Skipping.`,
132
+ );
133
+
134
+ continue;
135
+ }
136
+
137
+ for (const worker of workers) {
138
+ const { fullName, method, opts, isCron, cronPattern } = worker;
139
+
140
+ if (registeredJobs.has(fullName)) {
141
+ throw new Error(
142
+ `Monque: Duplicate job registration detected. Job "${fullName}" is already registered.`,
143
+ );
144
+ }
145
+
146
+ registeredJobs.add(fullName);
147
+
148
+ const handler = async (job: Job) => {
149
+ const $ctx = new DIContext({
150
+ injector: this.injector,
151
+ id: job._id?.toString() || 'unknown',
152
+ });
153
+ $ctx.set('MONQUE_JOB', job);
154
+ $ctx.container.set(DIContext, $ctx);
155
+
156
+ await runInContext($ctx, async () => {
157
+ try {
158
+ let targetInstance = instance;
159
+ if (provider.scope === ProviderScope.REQUEST || !targetInstance) {
160
+ targetInstance = await this.injector.invoke(provider.token, {
161
+ locals: $ctx.container,
162
+ });
163
+ }
164
+
165
+ const typedInstance = targetInstance as Record<string, (job: Job) => unknown>;
166
+
167
+ if (typedInstance && typeof typedInstance[method] === 'function') {
168
+ await typedInstance[method](job);
169
+ }
170
+ } catch (error) {
171
+ this.logger.error({
172
+ event: 'MONQUE_JOB_ERROR',
173
+ jobName: fullName,
174
+ jobId: job._id,
175
+ message: `Error processing job ${fullName}`,
176
+ error,
177
+ });
178
+ throw error;
179
+ } finally {
180
+ await $ctx.destroy();
181
+ }
182
+ });
183
+ };
184
+
185
+ if (isCron && cronPattern) {
186
+ this.logger.debug(`Monque: Registering cron job "${fullName}" (${cronPattern})`);
187
+
188
+ monque.register(fullName, handler, opts as WorkerOptions);
189
+ await monque.schedule(cronPattern, fullName, {}, opts as ScheduleOptions);
190
+ } else {
191
+ this.logger.debug(`Monque: Registering worker "${fullName}"`);
192
+ monque.register(fullName, handler, opts as WorkerOptions);
193
+ }
194
+ }
195
+ }
196
+
197
+ this.logger.info(`Monque: Registered ${registeredJobs.size} jobs`);
198
+ }
199
+ }
@@ -0,0 +1 @@
1
+ export { MonqueService } from './monque-service.js';
@@ -0,0 +1,267 @@
1
+ /**
2
+ * MonqueService - Injectable wrapper for Monque
3
+ *
4
+ * Provides a DI-friendly interface to the Monque job queue.
5
+ * All methods delegate to the underlying Monque instance.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * @Service()
10
+ * export class OrderService {
11
+ * @Inject()
12
+ * private monque: MonqueService;
13
+ *
14
+ * async createOrder(data: CreateOrderDto) {
15
+ * const order = await this.save(data);
16
+ * await this.monque.enqueue("order.process", { orderId: order.id });
17
+ * return order;
18
+ * }
19
+ * }
20
+ * ```
21
+ */
22
+
23
+ import type {
24
+ BulkOperationResult,
25
+ CursorOptions,
26
+ CursorPage,
27
+ EnqueueOptions,
28
+ GetJobsFilter,
29
+ JobSelector,
30
+ Monque,
31
+ PersistedJob,
32
+ QueueStats,
33
+ ScheduleOptions,
34
+ } from '@monque/core';
35
+ import { MonqueError } from '@monque/core';
36
+ import { Injectable } from '@tsed/di';
37
+ import { ObjectId } from 'mongodb';
38
+
39
+ /**
40
+ * Injectable service that wraps the Monque instance.
41
+ *
42
+ * Exposes the full Monque public API through dependency injection.
43
+ */
44
+ @Injectable()
45
+ export class MonqueService {
46
+ /**
47
+ * Internal Monque instance (set by MonqueModule)
48
+ * @internal
49
+ */
50
+ private _monque: Monque | null = null;
51
+
52
+ /**
53
+ * Set the internal Monque instance.
54
+ * Called by MonqueModule during initialization.
55
+ * @internal
56
+ */
57
+ _setMonque(monque: Monque): void {
58
+ this._monque = monque;
59
+ }
60
+
61
+ /**
62
+ * Access the underlying Monque instance.
63
+ * @throws Error if MonqueModule is not initialized
64
+ */
65
+ get monque(): Monque {
66
+ if (!this._monque) {
67
+ throw new MonqueError(
68
+ 'MonqueService is not initialized. Ensure MonqueModule is imported and enabled.',
69
+ );
70
+ }
71
+
72
+ return this._monque;
73
+ }
74
+
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+ // Job Scheduling
77
+ // ─────────────────────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Enqueue a job for processing.
81
+ *
82
+ * @param name - Job type identifier (use full namespaced name, e.g., "email.send")
83
+ * @param data - Job payload
84
+ * @param options - Scheduling and deduplication options
85
+ * @returns The created or existing job document
86
+ */
87
+ async enqueue<T>(name: string, data: T, options?: EnqueueOptions): Promise<PersistedJob<T>> {
88
+ return this.monque.enqueue(name, data, options);
89
+ }
90
+
91
+ /**
92
+ * Enqueue a job for immediate processing.
93
+ *
94
+ * @param name - Job type identifier
95
+ * @param data - Job payload
96
+ * @returns The created job document
97
+ */
98
+ async now<T>(name: string, data: T): Promise<PersistedJob<T>> {
99
+ return this.monque.now(name, data);
100
+ }
101
+
102
+ /**
103
+ * Schedule a recurring job with a cron expression.
104
+ *
105
+ * @param cron - Cron expression (5-field standard or predefined like @daily)
106
+ * @param name - Job type identifier
107
+ * @param data - Job payload
108
+ * @param options - Scheduling options
109
+ * @returns The created job document
110
+ */
111
+ async schedule<T>(
112
+ cron: string,
113
+ name: string,
114
+ data: T,
115
+ options?: ScheduleOptions,
116
+ ): Promise<PersistedJob<T>> {
117
+ return this.monque.schedule(cron, name, data, options);
118
+ }
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // Job Management (Single Job Operations)
122
+ // ─────────────────────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Cancel a pending or scheduled job.
126
+ *
127
+ * @param jobId - The ID of the job to cancel
128
+ * @returns The cancelled job, or null if not found
129
+ */
130
+ async cancelJob(jobId: string): Promise<PersistedJob<unknown> | null> {
131
+ return this.monque.cancelJob(jobId);
132
+ }
133
+
134
+ /**
135
+ * Retry a failed or cancelled job.
136
+ *
137
+ * @param jobId - The ID of the job to retry
138
+ * @returns The updated job, or null if not found
139
+ */
140
+ async retryJob(jobId: string): Promise<PersistedJob<unknown> | null> {
141
+ return this.monque.retryJob(jobId);
142
+ }
143
+
144
+ /**
145
+ * Reschedule a pending job to run at a different time.
146
+ *
147
+ * @param jobId - The ID of the job to reschedule
148
+ * @param runAt - The new Date when the job should run
149
+ * @returns The updated job, or null if not found
150
+ */
151
+ async rescheduleJob(jobId: string, runAt: Date): Promise<PersistedJob<unknown> | null> {
152
+ return this.monque.rescheduleJob(jobId, runAt);
153
+ }
154
+
155
+ /**
156
+ * Permanently delete a job.
157
+ *
158
+ * @param jobId - The ID of the job to delete
159
+ * @returns true if deleted, false if job not found
160
+ */
161
+ async deleteJob(jobId: string): Promise<boolean> {
162
+ return this.monque.deleteJob(jobId);
163
+ }
164
+
165
+ // ─────────────────────────────────────────────────────────────────────────────
166
+ // Job Management (Bulk Operations)
167
+ // ─────────────────────────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Cancel multiple jobs matching the given filter.
171
+ *
172
+ * @param filter - Selector for which jobs to cancel
173
+ * @returns Result with count of cancelled jobs
174
+ */
175
+ async cancelJobs(filter: JobSelector): Promise<BulkOperationResult> {
176
+ return this.monque.cancelJobs(filter);
177
+ }
178
+
179
+ /**
180
+ * Retry multiple jobs matching the given filter.
181
+ *
182
+ * @param filter - Selector for which jobs to retry
183
+ * @returns Result with count of retried jobs
184
+ */
185
+ async retryJobs(filter: JobSelector): Promise<BulkOperationResult> {
186
+ return this.monque.retryJobs(filter);
187
+ }
188
+
189
+ /**
190
+ * Delete multiple jobs matching the given filter.
191
+ *
192
+ * @param filter - Selector for which jobs to delete
193
+ * @returns Result with count of deleted jobs
194
+ */
195
+ async deleteJobs(filter: JobSelector): Promise<BulkOperationResult> {
196
+ return this.monque.deleteJobs(filter);
197
+ }
198
+
199
+ // ─────────────────────────────────────────────────────────────────────────────
200
+ // Job Queries
201
+ // ─────────────────────────────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Get a job by its ID.
205
+ *
206
+ * @param jobId - The job's ObjectId (as string or ObjectId)
207
+ * @returns The job document, or null if not found
208
+ * @throws MonqueError if jobId is an invalid hex string
209
+ */
210
+ async getJob<T>(jobId: string | ObjectId): Promise<PersistedJob<T> | null> {
211
+ let id: ObjectId;
212
+
213
+ if (typeof jobId === 'string') {
214
+ if (!ObjectId.isValid(jobId)) {
215
+ throw new MonqueError(`Invalid job ID format: ${jobId}`);
216
+ }
217
+ id = ObjectId.createFromHexString(jobId);
218
+ } else {
219
+ id = jobId;
220
+ }
221
+
222
+ return this.monque.getJob(id);
223
+ }
224
+
225
+ /**
226
+ * Query jobs from the queue with optional filters.
227
+ *
228
+ * @param filter - Optional filter criteria (name, status, limit, skip)
229
+ * @returns Array of matching jobs
230
+ */
231
+ async getJobs<T>(filter?: GetJobsFilter): Promise<PersistedJob<T>[]> {
232
+ return this.monque.getJobs(filter);
233
+ }
234
+
235
+ /**
236
+ * Get a paginated list of jobs using opaque cursors.
237
+ *
238
+ * @param options - Pagination options (cursor, limit, direction, filter)
239
+ * @returns Page of jobs with next/prev cursors
240
+ */
241
+ async getJobsWithCursor<T>(options?: CursorOptions): Promise<CursorPage<T>> {
242
+ return this.monque.getJobsWithCursor(options);
243
+ }
244
+
245
+ /**
246
+ * Get aggregate statistics for the job queue.
247
+ *
248
+ * @param filter - Optional filter to scope statistics by job name
249
+ * @returns Queue statistics
250
+ */
251
+ async getQueueStats(filter?: Pick<JobSelector, 'name'>): Promise<QueueStats> {
252
+ return this.monque.getQueueStats(filter);
253
+ }
254
+
255
+ // ─────────────────────────────────────────────────────────────────────────────
256
+ // Health Check
257
+ // ─────────────────────────────────────────────────────────────────────────────
258
+
259
+ /**
260
+ * Check if the scheduler is healthy and running.
261
+ *
262
+ * @returns true if running and connected
263
+ */
264
+ isHealthy(): boolean {
265
+ return this.monque.isHealthy();
266
+ }
267
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Build the full job name by combining namespace and name.
3
+ *
4
+ * @param namespace - Optional namespace from @WorkerController
5
+ * @param name - Job name from @Worker or @Cron
6
+ * @returns Full job name (e.g., "email.send" or just "send")
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * buildJobName("email", "send"); // "email.send"
11
+ * buildJobName(undefined, "send"); // "send"
12
+ * buildJobName("", "send"); // "send"
13
+ * ```
14
+ */
15
+ export function buildJobName(namespace: string | undefined, name: string): string {
16
+ return namespace ? `${namespace}.${name}` : name;
17
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Collect worker metadata utility
3
+ *
4
+ * Collects all worker metadata from a class decorated with @WorkerController.
5
+ * Used by MonqueModule to discover and register all workers.
6
+ */
7
+ import { Store } from '@tsed/core';
8
+
9
+ import { MONQUE } from '@/constants';
10
+ import type { CronDecoratorOptions, WorkerDecoratorOptions, WorkerStore } from '@/decorators';
11
+
12
+ import { buildJobName } from './build-job-name.js';
13
+
14
+ /**
15
+ * Collected worker registration info ready for Monque.register()
16
+ */
17
+ export interface CollectedWorkerMetadata {
18
+ /**
19
+ * Full job name (with namespace prefix if applicable)
20
+ */
21
+ fullName: string;
22
+
23
+ /**
24
+ * Method name on the controller class
25
+ */
26
+ method: string;
27
+
28
+ /**
29
+ * Worker options to pass to Monque.register()
30
+ */
31
+ opts: WorkerDecoratorOptions | CronDecoratorOptions;
32
+
33
+ /**
34
+ * Whether this is a cron job
35
+ */
36
+ isCron: boolean;
37
+
38
+ /**
39
+ * Cron pattern (only for cron jobs)
40
+ */
41
+ cronPattern?: string;
42
+ }
43
+
44
+ /**
45
+ * Collect all worker metadata from a class.
46
+ *
47
+ * @param target - The class constructor (decorated with @WorkerController)
48
+ * @returns Array of collected worker metadata ready for registration
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * const metadata = collectWorkerMetadata(EmailWorkers);
53
+ * // Returns:
54
+ * // [
55
+ * // { fullName: "email.send", method: "sendEmail", opts: {}, isCron: false },
56
+ * // { fullName: "email.daily-digest", method: "sendDailyDigest", opts: {}, isCron: true, cronPattern: "0 9 * * *" }
57
+ * // ]
58
+ * ```
59
+ */
60
+ export function collectWorkerMetadata(
61
+ target: new (...args: unknown[]) => unknown,
62
+ ): CollectedWorkerMetadata[] {
63
+ const store = Store.from(target);
64
+ const workerStore = store.get<WorkerStore>(MONQUE);
65
+
66
+ if (!workerStore) {
67
+ return [];
68
+ }
69
+
70
+ const results: CollectedWorkerMetadata[] = [];
71
+ const namespace = workerStore.namespace;
72
+
73
+ // Collect regular workers
74
+ for (const worker of workerStore.workers) {
75
+ results.push({
76
+ fullName: buildJobName(namespace, worker.name),
77
+ method: worker.method,
78
+ opts: worker.opts,
79
+ isCron: false,
80
+ });
81
+ }
82
+
83
+ // Collect cron jobs
84
+ for (const cron of workerStore.cronJobs) {
85
+ results.push({
86
+ fullName: buildJobName(namespace, cron.name),
87
+ method: cron.method,
88
+ opts: cron.opts,
89
+ isCron: true,
90
+ cronPattern: cron.pattern,
91
+ });
92
+ }
93
+
94
+ return results;
95
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Generate a unique token for a worker controller.
3
+ *
4
+ * Used internally by Ts.ED DI to identify worker controller providers.
5
+ * The token is based on the class name for debugging purposes.
6
+ *
7
+ * @param target - The class constructor
8
+ * @returns A Symbol token unique to this worker controller
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * @WorkerController("email")
13
+ * class EmailWorkers {}
14
+ *
15
+ * const token = getWorkerToken(EmailWorkers);
16
+ * // Symbol("monque:worker:EmailWorkers")
17
+ * ```
18
+ */
19
+ export function getWorkerToken(target: new (...args: unknown[]) => unknown): symbol {
20
+ const name = target.name?.trim();
21
+
22
+ if (!name) {
23
+ throw new Error('Worker class must have a non-empty name');
24
+ }
25
+
26
+ return Symbol.for(`monque:worker:${name}`);
27
+ }