mongo-job-scheduler 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.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/core/scheduler.d.ts +30 -0
- package/dist/core/scheduler.js +65 -0
- package/dist/events/emitter.d.ts +5 -0
- package/dist/events/emitter.js +17 -0
- package/dist/events/index.d.ts +2 -0
- package/dist/events/index.js +2 -0
- package/dist/events/typed-emitter.d.ts +8 -0
- package/dist/events/typed-emitter.js +21 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/store/in-memory-job-store.d.ts +23 -0
- package/dist/store/in-memory-job-store.js +103 -0
- package/dist/store/index.d.ts +3 -0
- package/dist/store/index.js +3 -0
- package/dist/store/job-store.d.ts +42 -0
- package/dist/store/job-store.js +1 -0
- package/dist/store/mongo/connect.d.ts +6 -0
- package/dist/store/mongo/connect.js +6 -0
- package/dist/store/mongo/index.d.ts +2 -0
- package/dist/store/mongo/index.js +2 -0
- package/dist/store/mongo/mongo-job-store.d.ts +29 -0
- package/dist/store/mongo/mongo-job-store.js +133 -0
- package/dist/store/mutex.d.ts +5 -0
- package/dist/store/mutex.js +24 -0
- package/dist/store/store-errors.d.ts +6 -0
- package/dist/store/store-errors.js +12 -0
- package/dist/types/events.d.ts +23 -0
- package/dist/types/events.js +1 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +5 -0
- package/dist/types/job.d.ts +20 -0
- package/dist/types/job.js +1 -0
- package/dist/types/lifecycle.d.ts +1 -0
- package/dist/types/lifecycle.js +1 -0
- package/dist/types/repeat.d.ts +14 -0
- package/dist/types/repeat.js +1 -0
- package/dist/types/retry.d.ts +12 -0
- package/dist/types/retry.js +1 -0
- package/dist/worker/index.d.ts +2 -0
- package/dist/worker/index.js +2 -0
- package/dist/worker/repeat.d.ts +2 -0
- package/dist/worker/repeat.js +14 -0
- package/dist/worker/retry.d.ts +2 -0
- package/dist/worker/retry.js +6 -0
- package/dist/worker/types.d.ts +20 -0
- package/dist/worker/types.js +1 -0
- package/dist/worker/worker.d.ts +19 -0
- package/dist/worker/worker.js +102 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Darshan Bhut
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Mongo Job Scheduler
|
|
2
|
+
|
|
3
|
+
A production-grade MongoDB-backed job scheduler for Node.js.
|
|
4
|
+
|
|
5
|
+
Designed for distributed systems that need:
|
|
6
|
+
|
|
7
|
+
- reliable background jobs
|
|
8
|
+
- retries with backoff
|
|
9
|
+
- cron & interval scheduling
|
|
10
|
+
- crash recovery
|
|
11
|
+
- MongoDB sharding safety
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Distributed locking** using MongoDB
|
|
18
|
+
- **Multiple workers** support
|
|
19
|
+
- **Retry with backoff**
|
|
20
|
+
- **Cron jobs** (timezone-aware, non-drifting)
|
|
21
|
+
- **Interval jobs**
|
|
22
|
+
- **Resume on restart**
|
|
23
|
+
- **Stale lock recovery**
|
|
24
|
+
- **Sharding-safe design**
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install mongo-job-scheduler
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Basic Usage
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { Scheduler, MongoJobStore } from "mongo-job-scheduler";
|
|
38
|
+
import { MongoClient } from "mongodb";
|
|
39
|
+
|
|
40
|
+
// ... connect to mongo ...
|
|
41
|
+
const client = new MongoClient("mongodb://localhost:27017");
|
|
42
|
+
await client.connect();
|
|
43
|
+
const db = client.db("my-app");
|
|
44
|
+
|
|
45
|
+
const scheduler = new Scheduler({
|
|
46
|
+
store: new MongoJobStore(db),
|
|
47
|
+
handler: async (job) => {
|
|
48
|
+
console.log("Running job:", job.name);
|
|
49
|
+
},
|
|
50
|
+
workers: 3, // default is 1
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await scheduler.start();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Cron with Timezone
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
await scheduler.schedule({
|
|
60
|
+
name: "daily-report",
|
|
61
|
+
repeat: {
|
|
62
|
+
cron: "0 9 * * *",
|
|
63
|
+
timezone: "Asia/Kolkata", // default is UTC
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Documentation
|
|
69
|
+
|
|
70
|
+
See `ARCHITECTURE.md` for:
|
|
71
|
+
|
|
72
|
+
- job lifecycle
|
|
73
|
+
- retry & repeat semantics
|
|
74
|
+
- MongoDB indexes
|
|
75
|
+
- sharding strategy
|
|
76
|
+
- production checklist
|
|
77
|
+
|
|
78
|
+
## Status
|
|
79
|
+
|
|
80
|
+
**Early-stage but production-tested.**
|
|
81
|
+
API may evolve before 1.0.0.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { SchedulerEventMap } from "../types/events";
|
|
2
|
+
import { JobStore } from "../store";
|
|
3
|
+
import { Job } from "../types/job";
|
|
4
|
+
export interface SchedulerOptions {
|
|
5
|
+
id?: string;
|
|
6
|
+
store?: JobStore;
|
|
7
|
+
handler?: (job: Job) => Promise<void>;
|
|
8
|
+
workers?: number;
|
|
9
|
+
pollIntervalMs?: number;
|
|
10
|
+
lockTimeoutMs?: number;
|
|
11
|
+
defaultTimezone?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class Scheduler {
|
|
14
|
+
private readonly emitter;
|
|
15
|
+
private readonly workers;
|
|
16
|
+
private started;
|
|
17
|
+
private readonly id;
|
|
18
|
+
private readonly store?;
|
|
19
|
+
private readonly handler?;
|
|
20
|
+
private readonly workerCount;
|
|
21
|
+
private readonly pollInterval;
|
|
22
|
+
private readonly lockTimeout;
|
|
23
|
+
private readonly defaultTimezone?;
|
|
24
|
+
constructor(options?: SchedulerOptions);
|
|
25
|
+
on<K extends keyof SchedulerEventMap>(event: K, listener: (payload: SchedulerEventMap[K]) => void): this;
|
|
26
|
+
start(): Promise<void>;
|
|
27
|
+
stop(): Promise<void>;
|
|
28
|
+
isRunning(): boolean;
|
|
29
|
+
getId(): string;
|
|
30
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { SchedulerEmitter } from "../events";
|
|
2
|
+
import { Worker } from "../worker";
|
|
3
|
+
export class Scheduler {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.emitter = new SchedulerEmitter();
|
|
6
|
+
this.workers = [];
|
|
7
|
+
this.started = false;
|
|
8
|
+
this.id = options.id ?? `scheduler-${Math.random().toString(36).slice(2)}`;
|
|
9
|
+
this.store = options.store;
|
|
10
|
+
this.handler = options.handler;
|
|
11
|
+
this.workerCount = options.workers ?? 1;
|
|
12
|
+
this.pollInterval = options.pollIntervalMs ?? 500;
|
|
13
|
+
this.lockTimeout = options.lockTimeoutMs ?? 30000;
|
|
14
|
+
this.defaultTimezone = options.defaultTimezone;
|
|
15
|
+
}
|
|
16
|
+
on(event, listener) {
|
|
17
|
+
this.emitter.on(event, listener);
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
async start() {
|
|
21
|
+
if (this.started)
|
|
22
|
+
return;
|
|
23
|
+
this.started = true;
|
|
24
|
+
this.emitter.emitSafe("scheduler:start", undefined);
|
|
25
|
+
if (this.store && typeof this.store.recoverStaleJobs === "function") {
|
|
26
|
+
await this.store.recoverStaleJobs({
|
|
27
|
+
now: new Date(),
|
|
28
|
+
lockTimeoutMs: this.lockTimeout,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// lifecycle-only mode (used by tests)
|
|
32
|
+
if (!this.store || !this.handler) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// -------------------------------
|
|
36
|
+
// start workers
|
|
37
|
+
// -------------------------------
|
|
38
|
+
for (let i = 0; i < this.workerCount; i++) {
|
|
39
|
+
const worker = new Worker(this.store, this.emitter, this.handler, {
|
|
40
|
+
pollIntervalMs: this.pollInterval,
|
|
41
|
+
lockTimeoutMs: this.lockTimeout,
|
|
42
|
+
workerId: `${this.id}-w${i}`,
|
|
43
|
+
defaultTimezone: this.defaultTimezone,
|
|
44
|
+
});
|
|
45
|
+
this.workers.push(worker);
|
|
46
|
+
await worker.start();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async stop() {
|
|
50
|
+
if (!this.started)
|
|
51
|
+
return;
|
|
52
|
+
this.started = false;
|
|
53
|
+
for (const worker of this.workers) {
|
|
54
|
+
await worker.stop();
|
|
55
|
+
}
|
|
56
|
+
this.workers.length = 0;
|
|
57
|
+
this.emitter.emitSafe("scheduler:stop", undefined);
|
|
58
|
+
}
|
|
59
|
+
isRunning() {
|
|
60
|
+
return this.started;
|
|
61
|
+
}
|
|
62
|
+
getId() {
|
|
63
|
+
return this.id;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { TypedEventEmitter } from "./typed-emitter";
|
|
2
|
+
import { SchedulerEventMap } from "../types/events";
|
|
3
|
+
export declare class SchedulerEmitter extends TypedEventEmitter<SchedulerEventMap> {
|
|
4
|
+
emitSafe<K extends keyof SchedulerEventMap>(event: K, payload: SchedulerEventMap[K]): void;
|
|
5
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { TypedEventEmitter } from "./typed-emitter";
|
|
2
|
+
export class SchedulerEmitter extends TypedEventEmitter {
|
|
3
|
+
emitSafe(event, payload) {
|
|
4
|
+
try {
|
|
5
|
+
this.emitUnsafe(event, payload);
|
|
6
|
+
}
|
|
7
|
+
catch (err) {
|
|
8
|
+
// never allow listener failure to crash core
|
|
9
|
+
try {
|
|
10
|
+
this.emitUnsafe("scheduler:error", err);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// absolute last guard
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type EventMap = Record<string, any>;
|
|
2
|
+
export declare class TypedEventEmitter<T extends EventMap> {
|
|
3
|
+
private emitter;
|
|
4
|
+
on<K extends keyof T>(event: K, listener: (payload: T[K]) => void): this;
|
|
5
|
+
once<K extends keyof T>(event: K, listener: (payload: T[K]) => void): this;
|
|
6
|
+
off<K extends keyof T>(event: K, listener: (payload: T[K]) => void): this;
|
|
7
|
+
protected emitUnsafe<K extends keyof T>(event: K, payload: T[K]): void;
|
|
8
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
export class TypedEventEmitter {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.emitter = new EventEmitter();
|
|
5
|
+
}
|
|
6
|
+
on(event, listener) {
|
|
7
|
+
this.emitter.on(event, listener);
|
|
8
|
+
return this;
|
|
9
|
+
}
|
|
10
|
+
once(event, listener) {
|
|
11
|
+
this.emitter.once(event, listener);
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
off(event, listener) {
|
|
15
|
+
this.emitter.off(event, listener);
|
|
16
|
+
return this;
|
|
17
|
+
}
|
|
18
|
+
emitUnsafe(event, payload) {
|
|
19
|
+
this.emitter.emit(event, payload);
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Scheduler } from "./core/scheduler";
|
|
2
|
+
export { MongoJobStore } from "./store/mongo/mongo-job-store";
|
|
3
|
+
export { InMemoryJobStore } from "./store/in-memory-job-store";
|
|
4
|
+
export * from "./types/job";
|
|
5
|
+
export * from "./types/retry";
|
|
6
|
+
export * from "./types/repeat";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Scheduler } from "./core/scheduler";
|
|
2
|
+
export { MongoJobStore } from "./store/mongo/mongo-job-store";
|
|
3
|
+
export { InMemoryJobStore } from "./store/in-memory-job-store";
|
|
4
|
+
export * from "./types/job";
|
|
5
|
+
export * from "./types/retry";
|
|
6
|
+
export * from "./types/repeat";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Job } from "../types/job";
|
|
2
|
+
import { JobStore } from "./job-store";
|
|
3
|
+
export declare class InMemoryJobStore implements JobStore {
|
|
4
|
+
private jobs;
|
|
5
|
+
private mutex;
|
|
6
|
+
private generateId;
|
|
7
|
+
create(job: Job): Promise<Job>;
|
|
8
|
+
findAndLockNext({ now, workerId, lockTimeoutMs, }: {
|
|
9
|
+
now: Date;
|
|
10
|
+
workerId: string;
|
|
11
|
+
lockTimeoutMs: number;
|
|
12
|
+
}): Promise<Job | null>;
|
|
13
|
+
markCompleted(jobId: unknown): Promise<void>;
|
|
14
|
+
markFailed(jobId: unknown, error: string): Promise<void>;
|
|
15
|
+
reschedule(jobId: unknown, nextRunAt: Date, updates?: {
|
|
16
|
+
attempts?: number;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
recoverStaleJobs({ now, lockTimeoutMs, }: {
|
|
19
|
+
now: Date;
|
|
20
|
+
lockTimeoutMs: number;
|
|
21
|
+
}): Promise<number>;
|
|
22
|
+
cancel(jobId: unknown): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { JobNotFoundError } from "./store-errors";
|
|
2
|
+
import { Mutex } from "./mutex";
|
|
3
|
+
export class InMemoryJobStore {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.jobs = new Map();
|
|
6
|
+
this.mutex = new Mutex();
|
|
7
|
+
}
|
|
8
|
+
generateId() {
|
|
9
|
+
return Math.random().toString(36).slice(2);
|
|
10
|
+
}
|
|
11
|
+
async create(job) {
|
|
12
|
+
const id = this.generateId();
|
|
13
|
+
const stored = {
|
|
14
|
+
...job,
|
|
15
|
+
_id: id,
|
|
16
|
+
createdAt: job.createdAt ?? new Date(),
|
|
17
|
+
updatedAt: job.updatedAt ?? new Date(),
|
|
18
|
+
};
|
|
19
|
+
this.jobs.set(id, stored);
|
|
20
|
+
return stored;
|
|
21
|
+
}
|
|
22
|
+
async findAndLockNext({ now, workerId, lockTimeoutMs, }) {
|
|
23
|
+
const release = await this.mutex.acquire();
|
|
24
|
+
try {
|
|
25
|
+
for (const job of this.jobs.values()) {
|
|
26
|
+
if (job.status !== "pending")
|
|
27
|
+
continue;
|
|
28
|
+
if (job.nextRunAt > now)
|
|
29
|
+
continue;
|
|
30
|
+
// lock expired?
|
|
31
|
+
if (job.lockedAt &&
|
|
32
|
+
now.getTime() - job.lockedAt.getTime() < lockTimeoutMs) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
job.status = "running";
|
|
36
|
+
job.lockedAt = now;
|
|
37
|
+
job.lockedBy = workerId;
|
|
38
|
+
job.updatedAt = new Date();
|
|
39
|
+
job.lastRunAt = now;
|
|
40
|
+
return { ...job };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
release();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async markCompleted(jobId) {
|
|
49
|
+
const job = this.jobs.get(String(jobId));
|
|
50
|
+
if (!job)
|
|
51
|
+
throw new JobNotFoundError();
|
|
52
|
+
job.status = "completed";
|
|
53
|
+
job.lastRunAt = new Date();
|
|
54
|
+
job.updatedAt = new Date();
|
|
55
|
+
}
|
|
56
|
+
async markFailed(jobId, error) {
|
|
57
|
+
const job = this.jobs.get(String(jobId));
|
|
58
|
+
if (!job)
|
|
59
|
+
throw new JobNotFoundError();
|
|
60
|
+
job.status = "failed";
|
|
61
|
+
job.lastError = error;
|
|
62
|
+
job.updatedAt = new Date();
|
|
63
|
+
}
|
|
64
|
+
async reschedule(jobId, nextRunAt, updates) {
|
|
65
|
+
const job = this.jobs.get(String(jobId));
|
|
66
|
+
if (!job)
|
|
67
|
+
throw new JobNotFoundError();
|
|
68
|
+
job.status = "pending";
|
|
69
|
+
job.nextRunAt = nextRunAt;
|
|
70
|
+
if (updates?.attempts != null) {
|
|
71
|
+
job.attempts = updates.attempts;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
job.attempts = (job.attempts ?? 0) + 1;
|
|
75
|
+
}
|
|
76
|
+
job.lockedAt = undefined;
|
|
77
|
+
job.lockedBy = undefined;
|
|
78
|
+
job.updatedAt = new Date();
|
|
79
|
+
job.lastScheduledAt = nextRunAt;
|
|
80
|
+
}
|
|
81
|
+
async recoverStaleJobs({ now, lockTimeoutMs, }) {
|
|
82
|
+
let recovered = 0;
|
|
83
|
+
for (const job of this.jobs.values()) {
|
|
84
|
+
if (job.status === "running" &&
|
|
85
|
+
job.lockedAt &&
|
|
86
|
+
now.getTime() - job.lockedAt.getTime() > lockTimeoutMs) {
|
|
87
|
+
job.status = "pending";
|
|
88
|
+
job.lockedAt = undefined;
|
|
89
|
+
job.lockedBy = undefined;
|
|
90
|
+
job.updatedAt = new Date();
|
|
91
|
+
recovered++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return recovered;
|
|
95
|
+
}
|
|
96
|
+
async cancel(jobId) {
|
|
97
|
+
const job = this.jobs.get(String(jobId));
|
|
98
|
+
if (!job)
|
|
99
|
+
throw new JobNotFoundError();
|
|
100
|
+
job.status = "cancelled";
|
|
101
|
+
job.updatedAt = new Date();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Job } from "../types/job";
|
|
2
|
+
export interface JobStore {
|
|
3
|
+
/**
|
|
4
|
+
* Insert a new job
|
|
5
|
+
*/
|
|
6
|
+
create(job: Job): Promise<Job>;
|
|
7
|
+
/**
|
|
8
|
+
* Find and lock the next runnable job.
|
|
9
|
+
* Must be atomic.
|
|
10
|
+
*/
|
|
11
|
+
findAndLockNext(options: {
|
|
12
|
+
now: Date;
|
|
13
|
+
workerId: string;
|
|
14
|
+
lockTimeoutMs: number;
|
|
15
|
+
}): Promise<Job | null>;
|
|
16
|
+
/**
|
|
17
|
+
* Mark job as completed
|
|
18
|
+
*/
|
|
19
|
+
markCompleted(jobId: unknown): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Mark job as failed
|
|
22
|
+
*/
|
|
23
|
+
markFailed(jobId: unknown, error: string): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Reschedule job (used for retry or repeat)
|
|
26
|
+
*/
|
|
27
|
+
reschedule(jobId: unknown, nextRunAt: Date, updates?: {
|
|
28
|
+
attempts?: number;
|
|
29
|
+
lastError?: string;
|
|
30
|
+
}): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Recover jobs stuck in running state
|
|
33
|
+
*/
|
|
34
|
+
recoverStaleJobs(options: {
|
|
35
|
+
now: Date;
|
|
36
|
+
lockTimeoutMs: number;
|
|
37
|
+
}): Promise<number>;
|
|
38
|
+
/**
|
|
39
|
+
* Cancel job explicitly
|
|
40
|
+
*/
|
|
41
|
+
cancel(jobId: unknown): Promise<void>;
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Db, ObjectId } from "mongodb";
|
|
2
|
+
import { JobStore } from "../job-store";
|
|
3
|
+
import { Job } from "../../types/job";
|
|
4
|
+
export interface MongoJobStoreOptions {
|
|
5
|
+
collectionName?: string;
|
|
6
|
+
lockTimeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare class MongoJobStore implements JobStore {
|
|
9
|
+
private readonly collection;
|
|
10
|
+
private readonly defaultLockTimeoutMs;
|
|
11
|
+
constructor(db: Db, options?: MongoJobStoreOptions);
|
|
12
|
+
create(job: Job): Promise<Job>;
|
|
13
|
+
findAndLockNext(options: {
|
|
14
|
+
now: Date;
|
|
15
|
+
workerId: string;
|
|
16
|
+
lockTimeoutMs: number;
|
|
17
|
+
}): Promise<Job | null>;
|
|
18
|
+
markCompleted(id: ObjectId): Promise<void>;
|
|
19
|
+
markFailed(id: ObjectId, error: string): Promise<void>;
|
|
20
|
+
reschedule(id: ObjectId, nextRunAt: Date, updates?: {
|
|
21
|
+
attempts?: number;
|
|
22
|
+
lastError?: string;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
cancel(id: ObjectId): Promise<void>;
|
|
25
|
+
recoverStaleJobs(options: {
|
|
26
|
+
now: Date;
|
|
27
|
+
lockTimeoutMs: number;
|
|
28
|
+
}): Promise<number>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export class MongoJobStore {
|
|
2
|
+
constructor(db, options = {}) {
|
|
3
|
+
this.collection = db.collection(options.collectionName ?? "scheduler_jobs");
|
|
4
|
+
this.defaultLockTimeoutMs = options.lockTimeoutMs ?? 30000;
|
|
5
|
+
}
|
|
6
|
+
// --------------------------------------------------
|
|
7
|
+
// CREATE
|
|
8
|
+
// --------------------------------------------------
|
|
9
|
+
async create(job) {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
// IMPORTANT: strip _id completely
|
|
12
|
+
const { _id, ...jobWithoutId } = job;
|
|
13
|
+
const doc = {
|
|
14
|
+
...jobWithoutId,
|
|
15
|
+
status: job.status ?? "pending",
|
|
16
|
+
attempts: job.attempts ?? 0,
|
|
17
|
+
createdAt: now,
|
|
18
|
+
updatedAt: now,
|
|
19
|
+
};
|
|
20
|
+
const result = await this.collection.insertOne(doc);
|
|
21
|
+
return { ...doc, _id: result.insertedId };
|
|
22
|
+
}
|
|
23
|
+
// --------------------------------------------------
|
|
24
|
+
// ATOMIC FIND & LOCK
|
|
25
|
+
// --------------------------------------------------
|
|
26
|
+
async findAndLockNext(options) {
|
|
27
|
+
const { now, workerId, lockTimeoutMs } = options;
|
|
28
|
+
const lockExpiry = new Date(now.getTime() - lockTimeoutMs);
|
|
29
|
+
const result = await this.collection.findOneAndUpdate({
|
|
30
|
+
status: "pending",
|
|
31
|
+
nextRunAt: { $lte: now },
|
|
32
|
+
$or: [
|
|
33
|
+
{ lockedAt: { $exists: false } },
|
|
34
|
+
{ lockedAt: { $lte: lockExpiry } },
|
|
35
|
+
],
|
|
36
|
+
}, {
|
|
37
|
+
$set: {
|
|
38
|
+
lockedAt: now,
|
|
39
|
+
lockedBy: workerId,
|
|
40
|
+
status: "running",
|
|
41
|
+
lastRunAt: now,
|
|
42
|
+
updatedAt: now,
|
|
43
|
+
},
|
|
44
|
+
}, {
|
|
45
|
+
sort: { nextRunAt: 1 },
|
|
46
|
+
returnDocument: "after",
|
|
47
|
+
});
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
// --------------------------------------------------
|
|
51
|
+
// MARK COMPLETED
|
|
52
|
+
// --------------------------------------------------
|
|
53
|
+
async markCompleted(id) {
|
|
54
|
+
await this.collection.updateOne({ _id: id }, {
|
|
55
|
+
$set: {
|
|
56
|
+
status: "completed",
|
|
57
|
+
updatedAt: new Date(),
|
|
58
|
+
},
|
|
59
|
+
$unset: {
|
|
60
|
+
lockedAt: "",
|
|
61
|
+
lockedBy: "",
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// --------------------------------------------------
|
|
66
|
+
// MARK FAILED
|
|
67
|
+
// --------------------------------------------------
|
|
68
|
+
async markFailed(id, error) {
|
|
69
|
+
await this.collection.updateOne({ _id: id }, {
|
|
70
|
+
$set: {
|
|
71
|
+
status: "failed",
|
|
72
|
+
lastError: error,
|
|
73
|
+
updatedAt: new Date(),
|
|
74
|
+
},
|
|
75
|
+
$unset: {
|
|
76
|
+
lockedAt: "",
|
|
77
|
+
lockedBy: "",
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// --------------------------------------------------
|
|
82
|
+
// RESCHEDULE
|
|
83
|
+
// --------------------------------------------------
|
|
84
|
+
async reschedule(id, nextRunAt, updates) {
|
|
85
|
+
await this.collection.updateOne({ _id: id }, {
|
|
86
|
+
$set: {
|
|
87
|
+
status: "pending",
|
|
88
|
+
nextRunAt,
|
|
89
|
+
updatedAt: new Date(),
|
|
90
|
+
...(updates ?? {}),
|
|
91
|
+
},
|
|
92
|
+
$unset: {
|
|
93
|
+
lockedAt: "",
|
|
94
|
+
lockedBy: "",
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// --------------------------------------------------
|
|
99
|
+
// CANCEL
|
|
100
|
+
// --------------------------------------------------
|
|
101
|
+
async cancel(id) {
|
|
102
|
+
await this.collection.updateOne({ _id: id }, {
|
|
103
|
+
$set: {
|
|
104
|
+
status: "failed",
|
|
105
|
+
updatedAt: new Date(),
|
|
106
|
+
},
|
|
107
|
+
$unset: {
|
|
108
|
+
lockedAt: "",
|
|
109
|
+
lockedBy: "",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// --------------------------------------------------
|
|
114
|
+
// RECOVER STALE JOBS
|
|
115
|
+
// --------------------------------------------------
|
|
116
|
+
async recoverStaleJobs(options) {
|
|
117
|
+
const { now, lockTimeoutMs } = options;
|
|
118
|
+
const expiry = new Date(now.getTime() - lockTimeoutMs);
|
|
119
|
+
const result = await this.collection.updateMany({
|
|
120
|
+
lockedAt: { $lte: expiry },
|
|
121
|
+
}, {
|
|
122
|
+
$set: {
|
|
123
|
+
status: "pending",
|
|
124
|
+
updatedAt: now,
|
|
125
|
+
},
|
|
126
|
+
$unset: {
|
|
127
|
+
lockedAt: "",
|
|
128
|
+
lockedBy: "",
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
return result.modifiedCount;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class Mutex {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.locked = false;
|
|
4
|
+
this.waiting = [];
|
|
5
|
+
}
|
|
6
|
+
async acquire() {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const release = () => {
|
|
9
|
+
const next = this.waiting.shift();
|
|
10
|
+
if (next)
|
|
11
|
+
next();
|
|
12
|
+
else
|
|
13
|
+
this.locked = false;
|
|
14
|
+
};
|
|
15
|
+
if (!this.locked) {
|
|
16
|
+
this.locked = true;
|
|
17
|
+
resolve(release);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
this.waiting.push(() => resolve(release));
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class JobNotFoundError extends Error {
|
|
2
|
+
constructor(message = "Job not found") {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "JobNotFoundError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class JobLockError extends Error {
|
|
8
|
+
constructor(message = "Failed to acquire job lock") {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "JobLockError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Job } from "./job";
|
|
2
|
+
export type SchedulerEventMap = {
|
|
3
|
+
"scheduler:start": void;
|
|
4
|
+
"scheduler:stop": void;
|
|
5
|
+
"scheduler:error": Error;
|
|
6
|
+
"job:created": Job;
|
|
7
|
+
"job:queued": Job;
|
|
8
|
+
"job:start": Job;
|
|
9
|
+
"job:success": Job;
|
|
10
|
+
"job:fail": {
|
|
11
|
+
job: Job;
|
|
12
|
+
error: Error;
|
|
13
|
+
};
|
|
14
|
+
"job:retry": Job;
|
|
15
|
+
"job:complete": Job;
|
|
16
|
+
"job:cancel": Job;
|
|
17
|
+
"resume:start": void;
|
|
18
|
+
"resume:jobRecovered": Job;
|
|
19
|
+
"resume:complete": void;
|
|
20
|
+
"worker:start": string;
|
|
21
|
+
"worker:stop": string;
|
|
22
|
+
"worker:error": Error;
|
|
23
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { JobStatus } from "./lifecycle";
|
|
2
|
+
import { RetryOptions } from "./retry";
|
|
3
|
+
import { RepeatOptions } from "./repeat";
|
|
4
|
+
export interface Job<Data = unknown> {
|
|
5
|
+
_id?: unknown;
|
|
6
|
+
name: string;
|
|
7
|
+
data: Data;
|
|
8
|
+
status: JobStatus;
|
|
9
|
+
nextRunAt: Date;
|
|
10
|
+
lastRunAt?: Date;
|
|
11
|
+
lastScheduledAt?: Date;
|
|
12
|
+
lockedAt?: Date;
|
|
13
|
+
lockedBy?: string;
|
|
14
|
+
attempts: number;
|
|
15
|
+
lastError?: string;
|
|
16
|
+
retry?: RetryOptions;
|
|
17
|
+
repeat?: RepeatOptions;
|
|
18
|
+
createdAt: Date;
|
|
19
|
+
updatedAt: Date;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type JobStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Total allowed attempts (including first)
|
|
4
|
+
*/
|
|
5
|
+
maxAttempts: number;
|
|
6
|
+
/**
|
|
7
|
+
* Backoff strategy:
|
|
8
|
+
* - number = fixed delay (ms)
|
|
9
|
+
* - function = dynamic delay
|
|
10
|
+
*/
|
|
11
|
+
delay: number | ((attempt: number) => number);
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { parseExpression } from "cron-parser";
|
|
2
|
+
export function getNextRunAt(repeat, base, defaultTimezone) {
|
|
3
|
+
if (repeat.every != null) {
|
|
4
|
+
return new Date(base.getTime() + repeat.every);
|
|
5
|
+
}
|
|
6
|
+
if (repeat.cron) {
|
|
7
|
+
const interval = parseExpression(repeat.cron, {
|
|
8
|
+
currentDate: base,
|
|
9
|
+
tz: repeat.timezone ?? defaultTimezone ?? "UTC",
|
|
10
|
+
});
|
|
11
|
+
return interval.next().toDate();
|
|
12
|
+
}
|
|
13
|
+
throw new Error("Invalid repeat configuration");
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Job } from "../types/job";
|
|
2
|
+
export type JobHandler<T = any> = (job: Job<T>) => Promise<void>;
|
|
3
|
+
export interface WorkerOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Interval between polling attempts (ms)
|
|
6
|
+
*/
|
|
7
|
+
pollIntervalMs?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Lock timeout for stale job recovery
|
|
10
|
+
*/
|
|
11
|
+
lockTimeoutMs?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Worker id (used for locking)
|
|
14
|
+
*/
|
|
15
|
+
workerId?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Default timezone for cron scheduling
|
|
18
|
+
*/
|
|
19
|
+
defaultTimezone?: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { JobStore } from "../store";
|
|
2
|
+
import { SchedulerEmitter } from "../events";
|
|
3
|
+
import { WorkerOptions, JobHandler } from "./types";
|
|
4
|
+
export declare class Worker {
|
|
5
|
+
private readonly store;
|
|
6
|
+
private readonly emitter;
|
|
7
|
+
private readonly handler;
|
|
8
|
+
private running;
|
|
9
|
+
private readonly pollInterval;
|
|
10
|
+
private readonly lockTimeout;
|
|
11
|
+
private readonly workerId;
|
|
12
|
+
private readonly defaultTimezone?;
|
|
13
|
+
constructor(store: JobStore, emitter: SchedulerEmitter, handler: JobHandler, options?: WorkerOptions);
|
|
14
|
+
start(): Promise<void>;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
private loop;
|
|
17
|
+
private execute;
|
|
18
|
+
private sleep;
|
|
19
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { getRetryDelay } from "./retry";
|
|
2
|
+
import { getNextRunAt } from "./repeat";
|
|
3
|
+
export class Worker {
|
|
4
|
+
constructor(store, emitter, handler, options = {}) {
|
|
5
|
+
this.store = store;
|
|
6
|
+
this.emitter = emitter;
|
|
7
|
+
this.handler = handler;
|
|
8
|
+
this.running = false;
|
|
9
|
+
this.pollInterval = options.pollIntervalMs ?? 500;
|
|
10
|
+
this.lockTimeout = options.lockTimeoutMs ?? 30000;
|
|
11
|
+
this.workerId =
|
|
12
|
+
options.workerId ?? `worker-${Math.random().toString(36).slice(2)}`;
|
|
13
|
+
this.defaultTimezone = options.defaultTimezone;
|
|
14
|
+
}
|
|
15
|
+
async start() {
|
|
16
|
+
if (this.running)
|
|
17
|
+
return;
|
|
18
|
+
this.running = true;
|
|
19
|
+
this.emitter.emitSafe("worker:start", this.workerId);
|
|
20
|
+
this.loop().catch((err) => {
|
|
21
|
+
this.emitter.emitSafe("worker:error", err);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async stop() {
|
|
25
|
+
this.running = false;
|
|
26
|
+
this.emitter.emitSafe("worker:stop", this.workerId);
|
|
27
|
+
}
|
|
28
|
+
async loop() {
|
|
29
|
+
while (this.running) {
|
|
30
|
+
// stop requested before poll
|
|
31
|
+
if (!this.running)
|
|
32
|
+
break;
|
|
33
|
+
const job = await this.store.findAndLockNext({
|
|
34
|
+
now: new Date(),
|
|
35
|
+
workerId: this.workerId,
|
|
36
|
+
lockTimeoutMs: this.lockTimeout,
|
|
37
|
+
});
|
|
38
|
+
// stop requested after polling
|
|
39
|
+
if (!this.running)
|
|
40
|
+
break;
|
|
41
|
+
if (!job) {
|
|
42
|
+
await this.sleep(this.pollInterval);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
await this.execute(job);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async execute(job) {
|
|
49
|
+
this.emitter.emitSafe("job:start", job);
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
// ---------------------------
|
|
52
|
+
// CRON: pre-schedule BEFORE execution
|
|
53
|
+
// ---------------------------
|
|
54
|
+
if (job.repeat?.cron) {
|
|
55
|
+
let base = job.lastScheduledAt ?? job.nextRunAt ?? new Date(now);
|
|
56
|
+
let next = getNextRunAt(job.repeat, base, this.defaultTimezone);
|
|
57
|
+
// skip missed cron slots
|
|
58
|
+
while (next.getTime() <= now) {
|
|
59
|
+
base = next;
|
|
60
|
+
next = getNextRunAt(job.repeat, base, this.defaultTimezone);
|
|
61
|
+
}
|
|
62
|
+
// persist schedule immediately
|
|
63
|
+
job.lastScheduledAt = next;
|
|
64
|
+
await this.store.reschedule(job._id, next);
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
await this.handler(job);
|
|
68
|
+
// ---------------------------
|
|
69
|
+
// INTERVAL: schedule AFTER execution
|
|
70
|
+
// ---------------------------
|
|
71
|
+
if (job.repeat?.every != null) {
|
|
72
|
+
const next = new Date(Date.now() + Math.max(job.repeat.every, 100));
|
|
73
|
+
await this.store.reschedule(job._id, next);
|
|
74
|
+
}
|
|
75
|
+
if (!job.repeat) {
|
|
76
|
+
await this.store.markCompleted(job._id);
|
|
77
|
+
this.emitter.emitSafe("job:success", job);
|
|
78
|
+
}
|
|
79
|
+
this.emitter.emitSafe("job:complete", job);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
83
|
+
const attempts = (job.attempts ?? 0) + 1;
|
|
84
|
+
const retry = job.retry;
|
|
85
|
+
if (retry && attempts < retry.maxAttempts) {
|
|
86
|
+
const nextRunAt = new Date(Date.now() + getRetryDelay(retry, attempts));
|
|
87
|
+
await this.store.reschedule(job._id, nextRunAt, { attempts });
|
|
88
|
+
this.emitter.emitSafe("job:retry", {
|
|
89
|
+
...job,
|
|
90
|
+
attempts,
|
|
91
|
+
lastError: error.message,
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
await this.store.markFailed(job._id, error.message);
|
|
96
|
+
this.emitter.emitSafe("job:fail", { job, error });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
sleep(ms) {
|
|
100
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
101
|
+
}
|
|
102
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mongo-job-scheduler",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Production-grade MongoDB-backed job scheduler with retries, cron, timezone support, and crash recovery",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Darshan Bhut",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/darshanpatel14/mongo-job-scheduler.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/darshanpatel14/mongo-job-scheduler#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/darshanpatel14/mongo-job-scheduler/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"types": "dist/index.d.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=16.0.0"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"job-scheduler",
|
|
25
|
+
"mongodb",
|
|
26
|
+
"mongo",
|
|
27
|
+
"cron",
|
|
28
|
+
"cron-jobs",
|
|
29
|
+
"background-jobs",
|
|
30
|
+
"task-scheduler",
|
|
31
|
+
"distributed-jobs",
|
|
32
|
+
"queue",
|
|
33
|
+
"retry",
|
|
34
|
+
"backoff",
|
|
35
|
+
"worker",
|
|
36
|
+
"agenda",
|
|
37
|
+
"bull",
|
|
38
|
+
"node-cron",
|
|
39
|
+
"timezone",
|
|
40
|
+
"scheduler"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc",
|
|
44
|
+
"test": "jest",
|
|
45
|
+
"test:mongo": "jest tests/mongo",
|
|
46
|
+
"test:stress": "jest tests/stress",
|
|
47
|
+
"prepublishOnly": "npm run build && npm test"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"cron-parser": "^4.9.0",
|
|
51
|
+
"mongodb": "^7.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/jest": "^30.0.0",
|
|
55
|
+
"jest": "^30.2.0",
|
|
56
|
+
"ts-jest": "^29.4.6",
|
|
57
|
+
"ts-node": "^10.9.2",
|
|
58
|
+
"typescript": "^5.9.3"
|
|
59
|
+
}
|
|
60
|
+
}
|