@hazeljs/cron 0.2.0-rc.2 → 0.2.0-rc.5

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.
@@ -1,9 +1,15 @@
1
+ import type { HazelApp } from '@hazeljs/core';
1
2
  import { CronOptions, CronJobStatus } from './cron.types';
2
3
  /**
3
4
  * Cron service for managing scheduled jobs
4
5
  */
5
6
  export declare class CronService {
6
7
  private jobs;
8
+ /**
9
+ * Auto-discover and register cron jobs from all providers with @Cron decorators.
10
+ * Called automatically after the application boots.
11
+ */
12
+ onApplicationBootstrap(_app: HazelApp): Promise<void>;
7
13
  /**
8
14
  * Register a new cron job
9
15
  */
@@ -1 +1 @@
1
- {"version":3,"file":"cron.service.d.ts","sourceRoot":"","sources":["../src/cron.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAgK1D;;GAEG;AACH,qBACa,WAAW;IACtB,OAAO,CAAC,IAAI,CAA8B;IAE1C;;OAEG;IACH,WAAW,CACT,IAAI,EAAE,MAAM,EACZ,cAAc,EAAE,MAAM,EACtB,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,EACpC,OAAO,GAAE,WAA0C,GAClD,IAAI;IAeP;;OAEG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAWhC;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAU/B;;OAEG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAU9B;;OAEG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAUhC;;OAEG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAUjC;;OAEG;IACH,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS;IAKrD;;OAEG;IACH,iBAAiB,IAAI,aAAa,EAAE;IAIpC;;OAEG;IACH,OAAO,IAAI,IAAI;IAKf;;OAEG;IACH,QAAQ,IAAI,IAAI;IAKhB;;OAEG;IACH,QAAQ,IAAI,IAAI;IAMhB;;OAEG;IACH,WAAW,IAAI,MAAM;CAGtB"}
1
+ {"version":3,"file":"cron.service.d.ts","sourceRoot":"","sources":["../src/cron.service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAsK1D;;GAEG;AACH,qBACa,WAAW;IACtB,OAAO,CAAC,IAAI,CAA8B;IAE1C;;;OAGG;IACG,sBAAsB,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IA+B3D;;OAEG;IACH,WAAW,CACT,IAAI,EAAE,MAAM,EACZ,cAAc,EAAE,MAAM,EACtB,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,EACpC,OAAO,GAAE,WAA0C,GAClD,IAAI;IAeP;;OAEG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAWhC;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAU/B;;OAEG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAU9B;;OAEG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAUhC;;OAEG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAUjC;;OAEG;IACH,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS;IAKrD;;OAEG;IACH,iBAAiB,IAAI,aAAa,EAAE;IAIpC;;OAEG;IACH,OAAO,IAAI,IAAI;IAKf;;OAEG;IACH,QAAQ,IAAI,IAAI;IAKhB;;OAEG;IACH,QAAQ,IAAI,IAAI;IAMhB;;OAEG;IACH,WAAW,IAAI,MAAM;CAGtB"}
@@ -11,8 +11,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.CronService = void 0;
13
13
  const core_1 = require("@hazeljs/core");
14
+ const cron_decorator_1 = require("./cron.decorator");
14
15
  const core_2 = __importDefault(require("@hazeljs/core"));
15
- const node_cron_1 = __importDefault(require("node-cron"));
16
+ /** Lazy-load node-cron to avoid blocking startup (require can hang in some environments) */
17
+ function getCron() {
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- intentional lazy load
19
+ return require('node-cron');
20
+ }
16
21
  /**
17
22
  * Represents a scheduled cron job
18
23
  * Uses node-cron for proper cron expression parsing and scheduling
@@ -31,7 +36,7 @@ class CronJob {
31
36
  // node-cron uses 5-field (minute-level) or 6-field (second-level) expressions
32
37
  // Validate the expression upfront
33
38
  const expr = this.normalizeExpression(this.cronExpression);
34
- if (!node_cron_1.default.validate(expr)) {
39
+ if (!getCron().validate(expr)) {
35
40
  throw new Error(`Invalid cron expression: ${this.cronExpression}. ` +
36
41
  `Format: second minute hour day-of-month month day-of-week`);
37
42
  }
@@ -56,7 +61,7 @@ class CronJob {
56
61
  return;
57
62
  }
58
63
  const expr = this.normalizeExpression(this.cronExpression);
59
- this.task = node_cron_1.default.schedule(expr, async () => {
64
+ this.task = getCron().schedule(expr, async () => {
60
65
  await this.execute();
61
66
  }, {
62
67
  scheduled: true,
@@ -149,6 +154,36 @@ let CronService = class CronService {
149
154
  constructor() {
150
155
  this.jobs = new Map();
151
156
  }
157
+ /**
158
+ * Auto-discover and register cron jobs from all providers with @Cron decorators.
159
+ * Called automatically after the application boots.
160
+ */
161
+ async onApplicationBootstrap(_app) {
162
+ const container = core_1.Container.getInstance();
163
+ const tokens = container.getTokens();
164
+ for (const token of tokens) {
165
+ if (typeof token !== 'function' || !token.prototype)
166
+ continue;
167
+ try {
168
+ const instance = container.resolve(token);
169
+ if (!instance || typeof instance !== 'object')
170
+ continue;
171
+ const metadata = (0, cron_decorator_1.getCronMetadata)(instance);
172
+ if (metadata && metadata.length > 0) {
173
+ for (const job of metadata) {
174
+ const callback = instance[job.methodName];
175
+ if (typeof callback === 'function') {
176
+ this.registerJob(job.options.name || job.methodName, job.options.cronTime, callback.bind(instance), job.options);
177
+ core_2.default.info(`Registered cron job: ${job.options.name || job.methodName}`);
178
+ }
179
+ }
180
+ }
181
+ }
182
+ catch {
183
+ // Skip request-scoped or unresolvable providers
184
+ }
185
+ }
186
+ }
152
187
  /**
153
188
  * Register a new cron job
154
189
  */
package/dist/cron.test.js CHANGED
@@ -156,6 +156,69 @@ describe('CronService', () => {
156
156
  expect(service.getJobCount()).toBe(0);
157
157
  });
158
158
  });
159
+ describe('onApplicationBootstrap()', () => {
160
+ it('discovers and registers cron jobs from container tokens', async () => {
161
+ class DiscoveredService {
162
+ run() {
163
+ // noop
164
+ }
165
+ }
166
+ __decorate([
167
+ (0, cron_decorator_1.Cron)({ cronTime: EVERY_MINUTE, name: 'discovered-job' }),
168
+ __metadata("design:type", Function),
169
+ __metadata("design:paramtypes", []),
170
+ __metadata("design:returntype", void 0)
171
+ ], DiscoveredService.prototype, "run", null);
172
+ const instance = new DiscoveredService();
173
+ const tokens = [DiscoveredService];
174
+ core_1.Container.getInstance.mockReturnValue({
175
+ getTokens: () => tokens,
176
+ resolve: (token) => (token === DiscoveredService ? instance : null),
177
+ });
178
+ await service.onApplicationBootstrap({});
179
+ expect(service.getJobCount()).toBe(1);
180
+ expect(service.getJobStatus('discovered-job')).toBeDefined();
181
+ });
182
+ it('skips tokens that are not functions', async () => {
183
+ core_1.Container.getInstance.mockReturnValue({
184
+ getTokens: () => ['string-token', 123],
185
+ resolve: jest.fn(),
186
+ });
187
+ await service.onApplicationBootstrap({});
188
+ expect(service.getJobCount()).toBe(0);
189
+ });
190
+ it('skips when resolve throws (request-scoped)', async () => {
191
+ class Unresolvable {
192
+ }
193
+ core_1.Container.getInstance.mockReturnValue({
194
+ getTokens: () => [Unresolvable],
195
+ resolve: () => {
196
+ throw new Error('request-scoped');
197
+ },
198
+ });
199
+ await expect(service.onApplicationBootstrap({})).resolves.not.toThrow();
200
+ expect(service.getJobCount()).toBe(0);
201
+ });
202
+ it('skips when metadata method is not a function on instance', async () => {
203
+ class NonFunctionMethod {
204
+ get myJob() {
205
+ return 'not callable';
206
+ }
207
+ }
208
+ __decorate([
209
+ (0, cron_decorator_1.Cron)({ cronTime: EVERY_MINUTE, name: 'getter-job' }),
210
+ __metadata("design:type", String),
211
+ __metadata("design:paramtypes", [])
212
+ ], NonFunctionMethod.prototype, "myJob", null);
213
+ const instance = new NonFunctionMethod();
214
+ core_1.Container.getInstance.mockReturnValue({
215
+ getTokens: () => [NonFunctionMethod],
216
+ resolve: () => instance,
217
+ });
218
+ await service.onApplicationBootstrap({});
219
+ expect(service.getJobCount()).toBe(0);
220
+ });
221
+ });
159
222
  describe('getJobCount()', () => {
160
223
  it('returns 0 when no jobs registered', () => {
161
224
  expect(service.getJobCount()).toBe(0);
@@ -180,6 +243,23 @@ describe('CronService', () => {
180
243
  expect(callback).toHaveBeenCalled();
181
244
  });
182
245
  });
246
+ describe('job options: maxRuns', () => {
247
+ it('stops job when maxRuns is reached', async () => {
248
+ const callback = jest.fn().mockResolvedValue(undefined);
249
+ service.registerJob('maxRuns-job', cron_types_1.CronExpression.EVERY_SECOND, callback, {
250
+ cronTime: cron_types_1.CronExpression.EVERY_SECOND,
251
+ runOnInit: true,
252
+ maxRuns: 1,
253
+ });
254
+ await new Promise((r) => setTimeout(r, 50));
255
+ expect(callback).toHaveBeenCalledTimes(1);
256
+ // Wait for schedule tick at 1s - should hit maxRuns branch and stop (no second callback)
257
+ await new Promise((r) => setTimeout(r, 1500));
258
+ expect(callback).toHaveBeenCalledTimes(1);
259
+ const status = service.getJobStatus('maxRuns-job');
260
+ expect(status?.runCount).toBe(1);
261
+ });
262
+ });
183
263
  describe('job options: onComplete / onError', () => {
184
264
  it('calls onComplete after successful execution (via runOnInit)', async () => {
185
265
  const onComplete = jest.fn();
@@ -219,6 +299,19 @@ describe('CronService', () => {
219
299
  expect(() => service.startJob('already-running')).not.toThrow();
220
300
  });
221
301
  });
302
+ describe('skip duplicate run when already executing', () => {
303
+ it('skips execution when job is already running (concurrent tick)', async () => {
304
+ const callback = jest.fn().mockImplementation(() => new Promise((r) => setTimeout(r, 2500)));
305
+ // Use every-second cron so a second tick can fire while first is still running
306
+ service.registerJob('slow-job', cron_types_1.CronExpression.EVERY_SECOND, callback, {
307
+ cronTime: cron_types_1.CronExpression.EVERY_SECOND,
308
+ runOnInit: true,
309
+ });
310
+ await new Promise((r) => setTimeout(r, 2500));
311
+ // First run (runOnInit) takes 2.5s; second tick at ~1s fires but is skipped (_isRunning)
312
+ expect(callback).toHaveBeenCalledTimes(1);
313
+ });
314
+ });
222
315
  });
223
316
  describe('Cron decorator', () => {
224
317
  it('attaches metadata to the class', () => {
@@ -380,6 +473,48 @@ describe('CronModule', () => {
380
473
  }
381
474
  expect(() => cron_module_1.CronModule.registerJobsFromProvider(new TaskService())).not.toThrow();
382
475
  });
476
+ it('handles resolve throwing (e.g. container error)', () => {
477
+ const cronService = new cron_service_1.CronService();
478
+ core_1.Container.getInstance.mockReturnValue({
479
+ resolve: jest.fn().mockImplementation(() => {
480
+ throw new Error('resolve failed');
481
+ }),
482
+ });
483
+ class TaskService {
484
+ doTask() {
485
+ // noop
486
+ }
487
+ }
488
+ __decorate([
489
+ (0, cron_decorator_1.Cron)({ cronTime: EVERY_MINUTE, name: 'task' }),
490
+ __metadata("design:type", Function),
491
+ __metadata("design:paramtypes", []),
492
+ __metadata("design:returntype", void 0)
493
+ ], TaskService.prototype, "doTask", null);
494
+ expect(() => cron_module_1.CronModule.registerJobsFromProvider(new TaskService())).not.toThrow();
495
+ cronService.clearAll();
496
+ });
497
+ it('skips when callback is not a function', () => {
498
+ const cronService = new cron_service_1.CronService();
499
+ core_1.Container.getInstance.mockReturnValue({
500
+ resolve: jest.fn().mockReturnValue(cronService),
501
+ });
502
+ class BadProvider {
503
+ get doTask() {
504
+ return 'not a function';
505
+ }
506
+ }
507
+ __decorate([
508
+ (0, cron_decorator_1.Cron)({ cronTime: EVERY_MINUTE, name: 'bad' }),
509
+ __metadata("design:type", String),
510
+ __metadata("design:paramtypes", [])
511
+ ], BadProvider.prototype, "doTask", null);
512
+ const instance = new BadProvider();
513
+ // getCronMetadata returns methodName 'doTask' but instance.doTask is a getter returning string
514
+ cron_module_1.CronModule.registerJobsFromProvider(instance);
515
+ expect(cronService.getJobCount()).toBe(0);
516
+ cronService.clearAll();
517
+ });
383
518
  });
384
519
  });
385
520
  describe('CronExpression constants', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hazeljs/cron",
3
- "version": "0.2.0-rc.2",
3
+ "version": "0.2.0-rc.5",
4
4
  "description": "Cron job scheduling module for HazelJS framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -50,5 +50,5 @@
50
50
  "peerDependencies": {
51
51
  "@hazeljs/core": ">=0.2.0-beta.0"
52
52
  },
53
- "gitHead": "d81cfed182d5236ca8aef4b855fbd3ee5b4fd048"
53
+ "gitHead": "84ad3db0f24f75a9d55c6eec5997849fed3aa00e"
54
54
  }