@modern-admin/queue 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.
Files changed (51) hide show
  1. package/dist/cron/cron-task.decorator.d.ts +30 -0
  2. package/dist/cron/cron-task.decorator.d.ts.map +1 -0
  3. package/dist/cron/cron-task.decorator.js +17 -0
  4. package/dist/cron/cron-task.decorator.js.map +1 -0
  5. package/dist/cron/cron.constants.d.ts +5 -0
  6. package/dist/cron/cron.constants.d.ts.map +1 -0
  7. package/dist/cron/cron.constants.js +5 -0
  8. package/dist/cron/cron.constants.js.map +1 -0
  9. package/dist/cron/cron.module.d.ts +19 -0
  10. package/dist/cron/cron.module.d.ts.map +1 -0
  11. package/dist/cron/cron.module.js +43 -0
  12. package/dist/cron/cron.module.js.map +1 -0
  13. package/dist/cron/cron.processor.d.ts +13 -0
  14. package/dist/cron/cron.processor.d.ts.map +1 -0
  15. package/dist/cron/cron.processor.js +83 -0
  16. package/dist/cron/cron.processor.js.map +1 -0
  17. package/dist/cron/cron.service.d.ts +38 -0
  18. package/dist/cron/cron.service.d.ts.map +1 -0
  19. package/dist/cron/cron.service.js +175 -0
  20. package/dist/cron/cron.service.js.map +1 -0
  21. package/dist/cron/cron.types.d.ts +10 -0
  22. package/dist/cron/cron.types.d.ts.map +1 -0
  23. package/dist/cron/cron.types.js +2 -0
  24. package/dist/cron/cron.types.js.map +1 -0
  25. package/dist/cron/index.d.ts +7 -0
  26. package/dist/cron/index.d.ts.map +1 -0
  27. package/dist/cron/index.js +6 -0
  28. package/dist/cron/index.js.map +1 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +4 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/queue.module.d.ts +49 -0
  34. package/dist/queue.module.d.ts.map +1 -0
  35. package/dist/queue.module.js +93 -0
  36. package/dist/queue.module.js.map +1 -0
  37. package/dist/queue.types.d.ts +20 -0
  38. package/dist/queue.types.d.ts.map +1 -0
  39. package/dist/queue.types.js +2 -0
  40. package/dist/queue.types.js.map +1 -0
  41. package/package.json +54 -0
  42. package/src/cron/cron-task.decorator.ts +34 -0
  43. package/src/cron/cron.constants.ts +4 -0
  44. package/src/cron/cron.module.ts +33 -0
  45. package/src/cron/cron.processor.ts +86 -0
  46. package/src/cron/cron.service.ts +178 -0
  47. package/src/cron/cron.types.ts +13 -0
  48. package/src/cron/index.ts +11 -0
  49. package/src/index.ts +19 -0
  50. package/src/queue.module.ts +94 -0
  51. package/src/queue.types.ts +16 -0
@@ -0,0 +1,93 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ 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;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var QueueModule_1;
8
+ import { Module } from '@nestjs/common';
9
+ import { BullModule } from '@nestjs/bullmq';
10
+ /**
11
+ * BullMQ integration module for modern-admin NestJS applications.
12
+ *
13
+ * 1. Call `QueueModule.forRoot()` once in the root AppModule to configure the
14
+ * Redis connection globally.
15
+ * 2. Call `QueueModule.register({ queues: [...] })` in any feature module to
16
+ * register named queues and make their injection tokens available.
17
+ *
18
+ * @example
19
+ * // AppModule
20
+ * imports: [
21
+ * QueueModule.forRoot({ connection: { host: 'localhost', port: 6379 } }),
22
+ * QueueModule.register({ queues: ['emails', 'exports'] }),
23
+ * CronModule,
24
+ * ]
25
+ */
26
+ let QueueModule = QueueModule_1 = class QueueModule {
27
+ /**
28
+ * Configure the BullMQ Redis connection for the whole application.
29
+ * Must be imported once at the root level before any `register()` call.
30
+ */
31
+ static forRoot(options) {
32
+ return {
33
+ module: QueueModule_1,
34
+ global: true,
35
+ imports: [
36
+ BullModule.forRoot({
37
+ connection: options.connection,
38
+ defaultJobOptions: options.defaultJobOptions,
39
+ }),
40
+ ],
41
+ };
42
+ }
43
+ /**
44
+ * Async variant of `forRoot` — useful when the Redis URL comes from a
45
+ * config service or environment variable loaded at runtime.
46
+ *
47
+ * @example
48
+ * QueueModule.forRootAsync({
49
+ * imports: [ConfigModule],
50
+ * inject: [ConfigService],
51
+ * useFactory: (cfg: ConfigService) => ({
52
+ * connection: cfg.get('REDIS_URL'),
53
+ * }),
54
+ * })
55
+ */
56
+ static forRootAsync(opts) {
57
+ return {
58
+ module: QueueModule_1,
59
+ global: true,
60
+ imports: [
61
+ BullModule.forRootAsync({
62
+ imports: opts.imports,
63
+ inject: opts.inject,
64
+ useFactory: async (...args) => {
65
+ const resolved = await opts.useFactory(...args);
66
+ return {
67
+ connection: resolved.connection,
68
+ defaultJobOptions: resolved.defaultJobOptions,
69
+ };
70
+ },
71
+ }),
72
+ ],
73
+ };
74
+ }
75
+ /**
76
+ * Register named queues (and optional flow producers) in the current module.
77
+ * Exports the BullMQ tokens so they can be injected via `@InjectQueue(name)`.
78
+ */
79
+ static register(options) {
80
+ const queueModules = options.queues.map((name) => BullModule.registerQueue({ name }));
81
+ const flowModules = (options.flows ?? []).map((name) => BullModule.registerFlowProducer({ name }));
82
+ return {
83
+ module: QueueModule_1,
84
+ imports: [...queueModules, ...flowModules],
85
+ exports: [...queueModules, ...flowModules],
86
+ };
87
+ }
88
+ };
89
+ QueueModule = QueueModule_1 = __decorate([
90
+ Module({})
91
+ ], QueueModule);
92
+ export { QueueModule };
93
+ //# sourceMappingURL=queue.module.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue.module.js","sourceRoot":"","sources":["../src/queue.module.ts"],"names":[],"mappings":";;;;;;;AAAA,OAAO,EAAsB,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAG3C;;;;;;;;;;;;;;;GAeG;AAEI,IAAM,WAAW,mBAAjB,MAAM,WAAW;IACtB;;;OAGG;IACH,MAAM,CAAC,OAAO,CAAC,OAAyB;QACtC,OAAO;YACL,MAAM,EAAE,aAAW;YACnB,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE;gBACP,UAAU,CAAC,OAAO,CAAC;oBACjB,UAAU,EAAE,OAAO,CAAC,UAAoB;oBACxC,iBAAiB,EAAE,OAAO,CAAC,iBAAiB;iBAC7C,CAAC;aACH;SACF,CAAA;IACH,CAAC;IAED;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,YAAY,CAAC,IAInB;QACC,OAAO;YACL,MAAM,EAAE,aAAW;YACnB,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE;gBACP,UAAU,CAAC,YAAY,CAAC;oBACtB,OAAO,EAAE,IAAI,CAAC,OAAO;oBACrB,MAAM,EAAE,IAAI,CAAC,MAAiB;oBAC9B,UAAU,EAAE,KAAK,EAAE,GAAG,IAAe,EAAE,EAAE;wBACvC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC,CAAA;wBAC/C,OAAO;4BACL,UAAU,EAAE,QAAQ,CAAC,UAAoB;4BACzC,iBAAiB,EAAE,QAAQ,CAAC,iBAAiB;yBAC9C,CAAA;oBACH,CAAC;iBACF,CAAC;aACH;SACF,CAAA;IACH,CAAC;IAED;;;OAGG;IACH,MAAM,CAAC,QAAQ,CAAC,OAA2B;QACzC,MAAM,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAC/C,UAAU,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,CAAC,CACnC,CAAA;QACD,MAAM,WAAW,GAAG,CAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CACrD,UAAU,CAAC,oBAAoB,CAAC,EAAE,IAAI,EAAE,CAAC,CAC1C,CAAA;QACD,OAAO;YACL,MAAM,EAAE,aAAW;YACnB,OAAO,EAAE,CAAC,GAAG,YAAY,EAAE,GAAG,WAAW,CAAC;YAC1C,OAAO,EAAE,CAAC,GAAG,YAAY,EAAE,GAAG,WAAW,CAAC;SAC3C,CAAA;IACH,CAAC;CACF,CAAA;AAxEY,WAAW;IADvB,MAAM,CAAC,EAAE,CAAC;GACE,WAAW,CAwEvB"}
@@ -0,0 +1,20 @@
1
+ import type { DefaultJobOptions } from 'bullmq';
2
+ export interface QueueModuleOptions {
3
+ queues: string[];
4
+ flows?: string[];
5
+ }
6
+ export interface QueueRootOptions {
7
+ /**
8
+ * ioredis-compatible connection. Accepts a connection string
9
+ * (`redis://...`) or a plain options object.
10
+ */
11
+ connection: {
12
+ host?: string;
13
+ port?: number;
14
+ password?: string;
15
+ db?: number;
16
+ } | string;
17
+ /** Default job options applied to every queue in this process. */
18
+ defaultJobOptions?: DefaultJobOptions;
19
+ }
20
+ //# sourceMappingURL=queue.types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue.types.d.ts","sourceRoot":"","sources":["../src/queue.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,QAAQ,CAAA;AAE/C,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,UAAU,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAA;IACrF,kEAAkE;IAClE,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;CACtC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=queue.types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue.types.js","sourceRoot":"","sources":["../src/queue.types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@modern-admin/queue",
3
+ "version": "0.1.0",
4
+ "description": "BullMQ-based queue + cron module for Modern Admin (NestJS).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/modern-admin/modern-admin.git",
10
+ "directory": "packages/queue"
11
+ },
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "publishConfig": {
25
+ "registry": "https://registry.npmjs.org",
26
+ "access": "public",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@modern-admin/tsconfig": "0.1.0",
38
+ "@nestjs/bullmq": "^11.0.4",
39
+ "@nestjs/common": "^11.1.19",
40
+ "@nestjs/core": "^11.1.19",
41
+ "@nestjs/testing": "^11.1.19",
42
+ "@types/bun": "^1.3.13",
43
+ "bullmq": "^5.77.2",
44
+ "reflect-metadata": "^0.2.2",
45
+ "rxjs": "^7.8.2",
46
+ "typescript": "^6.0.3"
47
+ },
48
+ "peerDependencies": {
49
+ "@nestjs/bullmq": "^11.0.4",
50
+ "@nestjs/common": "^11.0.0",
51
+ "@nestjs/core": "^11.0.0",
52
+ "bullmq": "^5.0.0"
53
+ }
54
+ }
@@ -0,0 +1,34 @@
1
+ import { SetMetadata } from '@nestjs/common'
2
+ import type { JobsOptions } from 'bullmq'
3
+
4
+ export const CRON_TASK_META = 'MODERN_ADMIN_CRON_TASK_META'
5
+
6
+ export interface CronTaskOptions {
7
+ /** Unique task name — used as the BullMQ job scheduler key. */
8
+ name: string
9
+ /** Cron expression, e.g. `"0 8 * * *"` for every day at 08:00 UTC. */
10
+ cron: string
11
+ /**
12
+ * When `true`, a distributed Redis lock is acquired before the handler runs.
13
+ * If the previous execution is still active, the new invocation is skipped.
14
+ */
15
+ skipIfRunning?: boolean
16
+ /** Extra BullMQ job options (excluding `repeat` which is controlled by `cron`). */
17
+ opts?: Omit<JobsOptions, 'repeat'>
18
+ }
19
+
20
+ /**
21
+ * Marks a service method as a BullMQ-backed cron task.
22
+ * The `CronService` discovers all decorated methods automatically on module init.
23
+ *
24
+ * @example
25
+ * @Injectable()
26
+ * export class ReportService {
27
+ * @CronTask({ name: 'daily-report', cron: '0 8 * * *' })
28
+ * async run(job: Job) {
29
+ * // ...
30
+ * }
31
+ * }
32
+ */
33
+ export const CronTask = (options: CronTaskOptions): MethodDecorator =>
34
+ SetMetadata(CRON_TASK_META, options)
@@ -0,0 +1,4 @@
1
+ export const CRON_QUEUE = 'ma:cron'
2
+ export const DEFAULT_CRON_WORKER_CONCURRENCY = 4
3
+ export const CRON_LOCK_PREFIX = 'ma:cron-lock:'
4
+ export const DEFAULT_CRON_LOCK_TTL = 300
@@ -0,0 +1,33 @@
1
+ import { Global, Module } from '@nestjs/common'
2
+ import { DiscoveryModule } from '@nestjs/core'
3
+ import { QueueModule } from '../queue.module.js'
4
+ import { CRON_QUEUE } from './cron.constants.js'
5
+ import { CronService } from './cron.service.js'
6
+ import { CronProcessor } from './cron.processor.js'
7
+
8
+ /**
9
+ * Global module that enables BullMQ-backed cron scheduling.
10
+ *
11
+ * Provides:
12
+ * - `CronService` — register tasks imperatively or via `@CronTask` decorator
13
+ * - `CronProcessor` — BullMQ worker that dispatches jobs to their handlers
14
+ *
15
+ * Requires `QueueModule.forRoot()` to be imported in the root module.
16
+ *
17
+ * @example
18
+ * // AppModule
19
+ * imports: [
20
+ * QueueModule.forRoot({ connection: { host: 'localhost', port: 6379 } }),
21
+ * CronModule,
22
+ * ]
23
+ */
24
+ @Global()
25
+ @Module({
26
+ imports: [
27
+ DiscoveryModule,
28
+ QueueModule.register({ queues: [CRON_QUEUE] }),
29
+ ],
30
+ providers: [CronService, CronProcessor],
31
+ exports: [CronService],
32
+ })
33
+ export class CronModule {}
@@ -0,0 +1,86 @@
1
+ import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq'
2
+ import { Logger } from '@nestjs/common'
3
+ import type { Job, Queue } from 'bullmq'
4
+ import {
5
+ CRON_LOCK_PREFIX,
6
+ CRON_QUEUE,
7
+ DEFAULT_CRON_LOCK_TTL,
8
+ DEFAULT_CRON_WORKER_CONCURRENCY,
9
+ } from './cron.constants.js'
10
+ import type { CronService } from './cron.service.js'
11
+
12
+ @Processor(CRON_QUEUE, { concurrency: DEFAULT_CRON_WORKER_CONCURRENCY })
13
+ export class CronProcessor extends WorkerHost {
14
+ private readonly logger = new Logger(CronProcessor.name)
15
+
16
+ constructor(
17
+ private readonly cronService: CronService,
18
+ @InjectQueue(CRON_QUEUE) private readonly cronQueue: Queue,
19
+ ) {
20
+ super()
21
+ }
22
+
23
+ async process(job: Job<unknown, unknown, string>): Promise<unknown> {
24
+ const handler = this.cronService.getHandler(job.name)
25
+ if (!handler) {
26
+ const msg = `No cron handler registered for "${job.name}"`
27
+ this.logger.error(msg)
28
+ throw new Error(msg)
29
+ }
30
+
31
+ return this.cronService.shouldSkipIfRunning(job.name)
32
+ ? this.processWithLock(job, handler)
33
+ : this.executeHandler(job, handler)
34
+ }
35
+
36
+ private async processWithLock(
37
+ job: Job<unknown, unknown, string>,
38
+ handler: (job: Job) => unknown,
39
+ ): Promise<unknown> {
40
+ const lockKey = `${CRON_LOCK_PREFIX}${job.name}`
41
+ const client = await this.cronQueue.client
42
+
43
+ // Atomic SET NX EX via the generic `runCommand` escape hatch on
44
+ // bullmq's `IRedisClient`. We can't use `client.set(..., { EX, NX })`
45
+ // because bullmq 5.77+ abstracted the client surface and dropped the
46
+ // `NX` option from `set`'s typed overloads (it now only accepts
47
+ // `{ PX?, EX? }`). `runCommand` is the documented portable way to
48
+ // reach any Redis command across ioredis / node-redis / bun-redis
49
+ // adapters. Returns the raw bulk-string reply ("OK") or nil.
50
+ const acquired = (await client.runCommand('set', [
51
+ lockKey,
52
+ job.id!,
53
+ 'EX',
54
+ DEFAULT_CRON_LOCK_TTL,
55
+ 'NX',
56
+ ])) as string | null
57
+ if (!acquired) {
58
+ this.logger.warn(
59
+ `Skipping "${job.name}" (jobId=${job.id}) — previous instance still running`,
60
+ )
61
+ return
62
+ }
63
+
64
+ try {
65
+ return await this.executeHandler(job, handler)
66
+ } finally {
67
+ await client.del(lockKey)
68
+ }
69
+ }
70
+
71
+ private async executeHandler(
72
+ job: Job<unknown, unknown, string>,
73
+ handler: (job: Job) => unknown,
74
+ ): Promise<unknown> {
75
+ const start = Date.now()
76
+ this.logger.log(`Cron task "${job.name}" started (jobId=${job.id})`)
77
+ try {
78
+ const result = await handler(job)
79
+ this.logger.log(`Cron task "${job.name}" completed in ${Date.now() - start}ms`)
80
+ return result
81
+ } catch (err: unknown) {
82
+ this.logger.error(`Cron task "${job.name}" failed after ${Date.now() - start}ms`, err)
83
+ throw err
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,178 @@
1
+ import {
2
+ Injectable,
3
+ Logger,
4
+ type OnModuleDestroy,
5
+ type OnModuleInit,
6
+ } from '@nestjs/common'
7
+ import { DiscoveryService, Reflector } from '@nestjs/core'
8
+ import { InjectQueue } from '@nestjs/bullmq'
9
+ import type { Job, Queue } from 'bullmq'
10
+ import { CRON_QUEUE } from './cron.constants.js'
11
+ import type { CronHandler, CronTaskDefinition } from './cron.types.js'
12
+ import { CRON_TASK_META, type CronTaskOptions } from './cron-task.decorator.js'
13
+
14
+ @Injectable()
15
+ export class CronService implements OnModuleInit, OnModuleDestroy {
16
+ private readonly logger = new Logger(CronService.name)
17
+ private readonly handlers = new Map<string, CronHandler>()
18
+ private readonly skipIfRunningSet = new Set<string>()
19
+ private readonly definitions: CronTaskDefinition[] = []
20
+ private initialized = false
21
+
22
+ constructor(
23
+ @InjectQueue(CRON_QUEUE) private readonly cronQueue: Queue,
24
+ private readonly discoveryService: DiscoveryService,
25
+ private readonly reflector: Reflector,
26
+ ) {}
27
+
28
+ /**
29
+ * Register a cron task imperatively.
30
+ * If called before `onModuleInit`, it is picked up by `syncSchedulers`.
31
+ * If called after `onModuleInit`, the scheduler is upserted in BullMQ immediately.
32
+ */
33
+ register<TData = unknown, TResult = unknown>(
34
+ definition: CronTaskDefinition<TData, TResult>,
35
+ ): void {
36
+ if (this.handlers.has(definition.name)) {
37
+ throw new Error(`Cron task "${definition.name}" is already registered`)
38
+ }
39
+ this.handlers.set(definition.name, definition.handler as CronHandler)
40
+ if (definition.skipIfRunning) this.skipIfRunningSet.add(definition.name)
41
+ this.definitions.push(definition as CronTaskDefinition)
42
+
43
+ if (this.initialized) {
44
+ void this.upsertScheduler(definition as CronTaskDefinition)
45
+ }
46
+ }
47
+
48
+ getHandler<TData = unknown, TResult = unknown>(
49
+ name: string,
50
+ ): CronHandler<TData, TResult> | undefined {
51
+ return this.handlers.get(name) as CronHandler<TData, TResult> | undefined
52
+ }
53
+
54
+ shouldSkipIfRunning(name: string): boolean {
55
+ return this.skipIfRunningSet.has(name)
56
+ }
57
+
58
+ async onModuleInit(): Promise<void> {
59
+ this.discoverCronTasks()
60
+ try {
61
+ await this.syncSchedulers()
62
+ } catch (err: unknown) {
63
+ this.logger.error('Failed to sync cron schedulers on init', err)
64
+ } finally {
65
+ this.initialized = true
66
+ }
67
+ }
68
+
69
+ async onModuleDestroy(): Promise<void> {
70
+ await this.cronQueue.close()
71
+ }
72
+
73
+ /**
74
+ * Remove specific cron tasks by name — deletes their schedulers and any
75
+ * waiting/delayed jobs from Redis.
76
+ */
77
+ async purgeTasks(names: string[]): Promise<void> {
78
+ const schedulers = await this.cronQueue.getJobSchedulers()
79
+ for (const s of schedulers) {
80
+ if (names.includes(s.name)) {
81
+ try {
82
+ await this.cronQueue.removeJobScheduler(s.key)
83
+ this.logger.log(`Purged scheduler: ${s.name}`)
84
+ } catch (err: unknown) {
85
+ this.logger.error(`Failed to purge scheduler ${s.name}`, err)
86
+ }
87
+ }
88
+ }
89
+
90
+ const jobs = (await this.cronQueue.getJobs(['waiting', 'delayed'])) as Job[]
91
+ for (const j of jobs) {
92
+ if (!j) continue
93
+ if (names.includes(j.name)) {
94
+ try {
95
+ await j.remove()
96
+ this.logger.log(`Purged job: ${j.name} (id=${j.id})`)
97
+ } catch (err: unknown) {
98
+ this.logger.error(`Failed to purge job ${j.name}`, err)
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ private discoverCronTasks(): void {
105
+ const providers = this.discoveryService.getProviders()
106
+ for (const wrapper of providers) {
107
+ const instance: unknown = wrapper.instance
108
+ if (!instance || typeof instance !== 'object') continue
109
+
110
+ const prototype = Object.getPrototypeOf(instance) as Record<string, unknown>
111
+ for (const key of Object.getOwnPropertyNames(prototype)) {
112
+ if (key === 'constructor') continue
113
+
114
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, key)
115
+ if (!descriptor || typeof descriptor.value !== 'function') continue
116
+
117
+ const meta = this.reflector.get<CronTaskOptions>(CRON_TASK_META, descriptor.value)
118
+ if (!meta) continue
119
+
120
+ const method = (instance as Record<string, unknown>)[key]
121
+ if (typeof method !== 'function') continue
122
+ this.register({
123
+ name: meta.name,
124
+ cron: meta.cron,
125
+ handler: (job) => method.call(instance, job),
126
+ skipIfRunning: meta.skipIfRunning,
127
+ opts: meta.opts,
128
+ })
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Synchronise repeatable job schedulers in Redis with the current definitions.
135
+ * Removes stale schedulers no longer defined in code, then upserts all current ones.
136
+ */
137
+ private async syncSchedulers(): Promise<void> {
138
+ const registeredNames = new Set(this.definitions.map((d) => d.name))
139
+
140
+ const existing = await this.cronQueue.getJobSchedulers()
141
+ for (const scheduler of existing) {
142
+ if (!registeredNames.has(scheduler.name)) {
143
+ try {
144
+ await this.cronQueue.removeJobScheduler(scheduler.key)
145
+ this.logger.log(
146
+ `Removed stale cron scheduler: ${scheduler.name} (key=${scheduler.key})`,
147
+ )
148
+ } catch (err: unknown) {
149
+ this.logger.error(`Failed to remove stale scheduler ${scheduler.name}`, err)
150
+ }
151
+ }
152
+ }
153
+
154
+ for (const def of this.definitions) {
155
+ await this.upsertScheduler(def)
156
+ }
157
+ }
158
+
159
+ private async upsertScheduler(def: CronTaskDefinition): Promise<void> {
160
+ try {
161
+ await this.cronQueue.upsertJobScheduler(
162
+ def.name,
163
+ { pattern: def.cron },
164
+ {
165
+ name: def.name,
166
+ opts: {
167
+ removeOnComplete: { count: 100 },
168
+ removeOnFail: { count: 100 },
169
+ ...def.opts,
170
+ },
171
+ },
172
+ )
173
+ this.logger.log(`Cron task scheduled: "${def.name}" [${def.cron}]`)
174
+ } catch (err: unknown) {
175
+ this.logger.error(`Failed to schedule cron task "${def.name}"`, err)
176
+ }
177
+ }
178
+ }
@@ -0,0 +1,13 @@
1
+ import type { Job, JobsOptions } from 'bullmq'
2
+
3
+ export type CronHandler<TData = unknown, TResult = unknown> = (
4
+ job: Job<TData, TResult, string>,
5
+ ) => Promise<TResult> | TResult
6
+
7
+ export interface CronTaskDefinition<TData = unknown, TResult = unknown> {
8
+ name: string
9
+ cron: string
10
+ handler: CronHandler<TData, TResult>
11
+ skipIfRunning?: boolean
12
+ opts?: Omit<JobsOptions, 'repeat'>
13
+ }
@@ -0,0 +1,11 @@
1
+ export { CronModule } from './cron.module.js'
2
+ export { CronService } from './cron.service.js'
3
+ export { CronProcessor } from './cron.processor.js'
4
+ export { CronTask, CRON_TASK_META, type CronTaskOptions } from './cron-task.decorator.js'
5
+ export type { CronHandler, CronTaskDefinition } from './cron.types.js'
6
+ export {
7
+ CRON_QUEUE,
8
+ CRON_LOCK_PREFIX,
9
+ DEFAULT_CRON_WORKER_CONCURRENCY,
10
+ DEFAULT_CRON_LOCK_TTL,
11
+ } from './cron.constants.js'
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // @modern-admin/queue — BullMQ queue + cron scheduling for NestJS.
2
+
3
+ export { QueueModule } from './queue.module.js'
4
+ export type { QueueModuleOptions, QueueRootOptions } from './queue.types.js'
5
+
6
+ export {
7
+ CronModule,
8
+ CronService,
9
+ CronProcessor,
10
+ CronTask,
11
+ CRON_TASK_META,
12
+ CRON_QUEUE,
13
+ CRON_LOCK_PREFIX,
14
+ DEFAULT_CRON_WORKER_CONCURRENCY,
15
+ DEFAULT_CRON_LOCK_TTL,
16
+ type CronTaskOptions,
17
+ type CronHandler,
18
+ type CronTaskDefinition,
19
+ } from './cron'
@@ -0,0 +1,94 @@
1
+ import { type DynamicModule, Module } from '@nestjs/common'
2
+ import { BullModule } from '@nestjs/bullmq'
3
+ import type { QueueModuleOptions, QueueRootOptions } from './queue.types.js'
4
+
5
+ /**
6
+ * BullMQ integration module for modern-admin NestJS applications.
7
+ *
8
+ * 1. Call `QueueModule.forRoot()` once in the root AppModule to configure the
9
+ * Redis connection globally.
10
+ * 2. Call `QueueModule.register({ queues: [...] })` in any feature module to
11
+ * register named queues and make their injection tokens available.
12
+ *
13
+ * @example
14
+ * // AppModule
15
+ * imports: [
16
+ * QueueModule.forRoot({ connection: { host: 'localhost', port: 6379 } }),
17
+ * QueueModule.register({ queues: ['emails', 'exports'] }),
18
+ * CronModule,
19
+ * ]
20
+ */
21
+ @Module({})
22
+ export class QueueModule {
23
+ /**
24
+ * Configure the BullMQ Redis connection for the whole application.
25
+ * Must be imported once at the root level before any `register()` call.
26
+ */
27
+ static forRoot(options: QueueRootOptions): DynamicModule {
28
+ return {
29
+ module: QueueModule,
30
+ global: true,
31
+ imports: [
32
+ BullModule.forRoot({
33
+ connection: options.connection as object,
34
+ defaultJobOptions: options.defaultJobOptions,
35
+ }),
36
+ ],
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Async variant of `forRoot` — useful when the Redis URL comes from a
42
+ * config service or environment variable loaded at runtime.
43
+ *
44
+ * @example
45
+ * QueueModule.forRootAsync({
46
+ * imports: [ConfigModule],
47
+ * inject: [ConfigService],
48
+ * useFactory: (cfg: ConfigService) => ({
49
+ * connection: cfg.get('REDIS_URL'),
50
+ * }),
51
+ * })
52
+ */
53
+ static forRootAsync(opts: {
54
+ imports?: DynamicModule['imports']
55
+ inject?: unknown[]
56
+ useFactory: (...args: unknown[]) => QueueRootOptions | Promise<QueueRootOptions>
57
+ }): DynamicModule {
58
+ return {
59
+ module: QueueModule,
60
+ global: true,
61
+ imports: [
62
+ BullModule.forRootAsync({
63
+ imports: opts.imports,
64
+ inject: opts.inject as never[],
65
+ useFactory: async (...args: unknown[]) => {
66
+ const resolved = await opts.useFactory(...args)
67
+ return {
68
+ connection: resolved.connection as object,
69
+ defaultJobOptions: resolved.defaultJobOptions,
70
+ }
71
+ },
72
+ }),
73
+ ],
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Register named queues (and optional flow producers) in the current module.
79
+ * Exports the BullMQ tokens so they can be injected via `@InjectQueue(name)`.
80
+ */
81
+ static register(options: QueueModuleOptions): DynamicModule {
82
+ const queueModules = options.queues.map((name) =>
83
+ BullModule.registerQueue({ name }),
84
+ )
85
+ const flowModules = (options.flows ?? []).map((name) =>
86
+ BullModule.registerFlowProducer({ name }),
87
+ )
88
+ return {
89
+ module: QueueModule,
90
+ imports: [...queueModules, ...flowModules],
91
+ exports: [...queueModules, ...flowModules],
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,16 @@
1
+ import type { DefaultJobOptions } from 'bullmq'
2
+
3
+ export interface QueueModuleOptions {
4
+ queues: string[]
5
+ flows?: string[]
6
+ }
7
+
8
+ export interface QueueRootOptions {
9
+ /**
10
+ * ioredis-compatible connection. Accepts a connection string
11
+ * (`redis://...`) or a plain options object.
12
+ */
13
+ connection: { host?: string; port?: number; password?: string; db?: number } | string
14
+ /** Default job options applied to every queue in this process. */
15
+ defaultJobOptions?: DefaultJobOptions
16
+ }