@hazeljs/cron 0.2.0-rc.3 → 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.
- package/dist/cron.service.d.ts +6 -0
- package/dist/cron.service.d.ts.map +1 -1
- package/dist/cron.service.js +38 -3
- package/dist/cron.test.js +135 -0
- package/package.json +2 -2
package/dist/cron.service.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/cron.service.js
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
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 =
|
|
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.
|
|
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": "
|
|
53
|
+
"gitHead": "84ad3db0f24f75a9d55c6eec5997849fed3aa00e"
|
|
54
54
|
}
|