@namnguyen.repl.it/nn-scheduler 1.0.0 → 2.0.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.
@@ -1,4 +1,4 @@
1
- import { IntervalTask, ScheduledTask, SchedulerConfig } from './types/SchedulerType';
1
+ import { IntervalTask, ScheduledTask, SchedulerConfig, JobRegistry, JobRecord } from "./types/SchedulerType";
2
2
  /**
3
3
  * Scheduler class for managing background tasks in Node.js, designed to run under PM2.
4
4
  * Supports two types of tasks:
@@ -11,11 +11,54 @@ export declare class Scheduler {
11
11
  private logHandler;
12
12
  private cronJobs;
13
13
  private intervalIds;
14
+ private db;
15
+ private jobHandlers;
16
+ private jobPollerInterval;
14
17
  /**
15
18
  * Creates a new Scheduler instance
16
- * @param config Configuration object with optional logHandler
19
+ * @param config Configuration object with optional logHandler and dbPath
17
20
  */
18
21
  constructor(config?: SchedulerConfig);
22
+ /**
23
+ * Initializes the SQLite database schema for job persistence
24
+ */
25
+ private initializeDatabase;
26
+ /**
27
+ * Registers job handlers that can be invoked by name
28
+ * @param jobs Object mapping job names to handler functions
29
+ */
30
+ registerJobs(jobs: JobRegistry): void;
31
+ /**
32
+ * Schedules a one-time job to run at a specific time
33
+ * @param time Time to run in "HH:MM" format (24-hour)
34
+ * @param jobName Name of the registered job handler
35
+ * @param data Data to pass to the job handler (can be any JSON-serializable value)
36
+ * @returns The job ID
37
+ */
38
+ job(time: string, jobName: string, data?: any): number;
39
+ /**
40
+ * Starts the internal job poller that checks for pending jobs every minute
41
+ */
42
+ private startJobPoller;
43
+ /**
44
+ * Polls the database for pending jobs that are ready to execute
45
+ */
46
+ private pollJobs;
47
+ /**
48
+ * Executes a single job and updates its status
49
+ */
50
+ private executeJob;
51
+ /**
52
+ * Gets all pending jobs from the database
53
+ * @returns Array of pending job records
54
+ */
55
+ getPendingJobs(): JobRecord[];
56
+ /**
57
+ * Cancels a scheduled job by ID
58
+ * @param jobId The job ID to cancel
59
+ * @returns True if job was cancelled, false if not found
60
+ */
61
+ cancelJob(jobId: number): boolean;
19
62
  /**
20
63
  * Converts a time string and weekdays into a cron expression
21
64
  * @param time Time in "HH:MM" format
package/dist/scheduler.js CHANGED
@@ -41,17 +41,21 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
41
41
  step((generator = generator.apply(thisArg, _arguments || [])).next());
42
42
  });
43
43
  };
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
44
47
  Object.defineProperty(exports, "__esModule", { value: true });
45
48
  exports.Scheduler = void 0;
46
49
  const cron = __importStar(require("node-cron"));
47
50
  const fs = __importStar(require("fs"));
48
51
  const path = __importStar(require("path"));
52
+ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
49
53
  /** Writes log messages to a file named by date (e.g., "2025-03-24.log") in the current directory */
50
54
  const defaultLogHandler = (message) => {
51
- const date = new Date().toISOString().split('T')[0];
55
+ const date = new Date().toISOString().split("T")[0];
52
56
  const logFile = path.join(process.cwd(), `${date}.log`);
53
57
  const logMessage = `${new Date().toISOString()} - ${message}\n`;
54
- fs.appendFileSync(logFile, logMessage, { encoding: 'utf8' });
58
+ fs.appendFileSync(logFile, logMessage, { encoding: "utf8" });
55
59
  };
56
60
  /**
57
61
  * Scheduler class for managing background tasks in Node.js, designed to run under PM2.
@@ -64,14 +68,174 @@ const defaultLogHandler = (message) => {
64
68
  class Scheduler {
65
69
  /**
66
70
  * Creates a new Scheduler instance
67
- * @param config Configuration object with optional logHandler
71
+ * @param config Configuration object with optional logHandler and dbPath
68
72
  */
69
73
  constructor(config = {}) {
70
74
  this.cronJobs = [];
71
75
  this.intervalIds = [];
76
+ this.jobHandlers = new Map();
77
+ this.jobPollerInterval = null;
72
78
  this.logHandler = config.logHandler || defaultLogHandler;
79
+ const dbPath = config.dbPath || path.join(process.cwd(), "scheduler.db");
80
+ this.db = new better_sqlite3_1.default(dbPath);
81
+ this.initializeDatabase();
82
+ this.startJobPoller();
73
83
  this.setupPM2();
74
84
  }
85
+ /**
86
+ * Initializes the SQLite database schema for job persistence
87
+ */
88
+ initializeDatabase() {
89
+ this.db.exec(`
90
+ CREATE TABLE IF NOT EXISTS jobs (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ job_name TEXT NOT NULL,
93
+ scheduled_at TEXT NOT NULL,
94
+ data TEXT,
95
+ status TEXT DEFAULT 'pending',
96
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
97
+ error TEXT
98
+ )
99
+ `);
100
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_jobs_status_scheduled ON jobs(status, scheduled_at)`);
101
+ this.logHandler("Database initialized");
102
+ }
103
+ /**
104
+ * Registers job handlers that can be invoked by name
105
+ * @param jobs Object mapping job names to handler functions
106
+ */
107
+ registerJobs(jobs) {
108
+ for (const [name, handler] of Object.entries(jobs)) {
109
+ this.jobHandlers.set(name, handler);
110
+ this.logHandler(`Registered job handler: ${name}`);
111
+ }
112
+ }
113
+ /**
114
+ * Schedules a one-time job to run at a specific time
115
+ * @param time Time to run in "HH:MM" format (24-hour)
116
+ * @param jobName Name of the registered job handler
117
+ * @param data Data to pass to the job handler (can be any JSON-serializable value)
118
+ * @returns The job ID
119
+ */
120
+ job(time, jobName, data) {
121
+ // Validate time format
122
+ const [hours, minutes] = time.split(":").map(Number);
123
+ if (isNaN(hours) ||
124
+ isNaN(minutes) ||
125
+ hours < 0 ||
126
+ hours > 23 ||
127
+ minutes < 0 ||
128
+ minutes > 59) {
129
+ throw new Error(`Invalid time format: ${time}`);
130
+ }
131
+ // Check if job handler exists
132
+ if (!this.jobHandlers.has(jobName)) {
133
+ throw new Error(`Job handler not registered: ${jobName}`);
134
+ }
135
+ // Calculate scheduled_at datetime
136
+ const now = new Date();
137
+ const scheduledAt = new Date(now);
138
+ scheduledAt.setHours(hours, minutes, 0, 0);
139
+ // If time has passed today, schedule for tomorrow
140
+ if (scheduledAt <= now) {
141
+ scheduledAt.setDate(scheduledAt.getDate() + 1);
142
+ }
143
+ const scheduledAtStr = scheduledAt.toISOString();
144
+ const dataStr = data !== undefined ? JSON.stringify(data) : null;
145
+ const stmt = this.db.prepare(`
146
+ INSERT INTO jobs (job_name, scheduled_at, data, status)
147
+ VALUES (?, ?, ?, 'pending')
148
+ `);
149
+ const result = stmt.run(jobName, scheduledAtStr, dataStr);
150
+ this.logHandler(`Scheduled job '${jobName}' for ${scheduledAtStr} (ID: ${result.lastInsertRowid})`);
151
+ return result.lastInsertRowid;
152
+ }
153
+ /**
154
+ * Starts the internal job poller that checks for pending jobs every minute
155
+ */
156
+ startJobPoller() {
157
+ // Run immediately on start
158
+ this.pollJobs();
159
+ // Then run every minute
160
+ this.jobPollerInterval = setInterval(() => {
161
+ this.pollJobs();
162
+ }, 60000); // 60 seconds
163
+ this.logHandler("Job poller started (runs every minute)");
164
+ }
165
+ /**
166
+ * Polls the database for pending jobs that are ready to execute
167
+ */
168
+ pollJobs() {
169
+ return __awaiter(this, void 0, void 0, function* () {
170
+ const now = new Date().toISOString();
171
+ const stmt = this.db.prepare(`
172
+ SELECT * FROM jobs
173
+ WHERE status = 'pending' AND scheduled_at <= ?
174
+ `);
175
+ const pendingJobs = stmt.all(now);
176
+ for (const job of pendingJobs) {
177
+ yield this.executeJob(job);
178
+ }
179
+ });
180
+ }
181
+ /**
182
+ * Executes a single job and updates its status
183
+ */
184
+ executeJob(job) {
185
+ return __awaiter(this, void 0, void 0, function* () {
186
+ const handler = this.jobHandlers.get(job.job_name);
187
+ if (!handler) {
188
+ this.logHandler(`Job handler not found: ${job.job_name} (ID: ${job.id})`);
189
+ this.db
190
+ .prepare(`UPDATE jobs SET status = 'failed', error = ? WHERE id = ?`)
191
+ .run("Handler not found", job.id);
192
+ return;
193
+ }
194
+ // Mark as processing
195
+ this.db
196
+ .prepare(`UPDATE jobs SET status = 'processing' WHERE id = ?`)
197
+ .run(job.id);
198
+ try {
199
+ const data = job.data ? JSON.parse(job.data) : undefined;
200
+ this.logHandler(`Executing job '${job.job_name}' (ID: ${job.id})`);
201
+ yield handler(data);
202
+ // Mark as completed and delete
203
+ this.db.prepare(`DELETE FROM jobs WHERE id = ?`).run(job.id);
204
+ this.logHandler(`Job '${job.job_name}' completed and removed (ID: ${job.id})`);
205
+ }
206
+ catch (error) {
207
+ const errorMessage = error instanceof Error ? error.message : String(error);
208
+ this.db
209
+ .prepare(`UPDATE jobs SET status = 'failed', error = ? WHERE id = ?`)
210
+ .run(errorMessage, job.id);
211
+ this.logHandler(`Job '${job.job_name}' failed (ID: ${job.id}): ${errorMessage}`);
212
+ }
213
+ });
214
+ }
215
+ /**
216
+ * Gets all pending jobs from the database
217
+ * @returns Array of pending job records
218
+ */
219
+ getPendingJobs() {
220
+ return this.db
221
+ .prepare(`SELECT * FROM jobs WHERE status = 'pending' ORDER BY scheduled_at`)
222
+ .all();
223
+ }
224
+ /**
225
+ * Cancels a scheduled job by ID
226
+ * @param jobId The job ID to cancel
227
+ * @returns True if job was cancelled, false if not found
228
+ */
229
+ cancelJob(jobId) {
230
+ const result = this.db
231
+ .prepare(`DELETE FROM jobs WHERE id = ? AND status = 'pending'`)
232
+ .run(jobId);
233
+ if (result.changes > 0) {
234
+ this.logHandler(`Cancelled job ID: ${jobId}`);
235
+ return true;
236
+ }
237
+ return false;
238
+ }
75
239
  /**
76
240
  * Converts a time string and weekdays into a cron expression
77
241
  * @param time Time in "HH:MM" format
@@ -80,11 +244,16 @@ class Scheduler {
80
244
  * @throws Error if time format is invalid
81
245
  */
82
246
  timeToCron(time, weekDays) {
83
- const [hours, minutes] = time.split(':').map(Number);
84
- if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
247
+ const [hours, minutes] = time.split(":").map(Number);
248
+ if (isNaN(hours) ||
249
+ isNaN(minutes) ||
250
+ hours < 0 ||
251
+ hours > 23 ||
252
+ minutes < 0 ||
253
+ minutes > 59) {
85
254
  throw new Error(`Invalid time format: ${time}`);
86
255
  }
87
- const weekDayStr = weekDays.includes('*') ? '*' : weekDays.join(',');
256
+ const weekDayStr = weekDays.includes("*") ? "*" : weekDays.join(",");
88
257
  return `${minutes} ${hours} * * ${weekDayStr}`;
89
258
  }
90
259
  // Converts a time string and matchPattern into a cron expression
@@ -93,30 +262,37 @@ class Scheduler {
93
262
  // @returns Cron expression string
94
263
  // @throws Error if time or pattern is invalid
95
264
  patternToCron(time, matchPattern) {
96
- const [hours, minutes] = time.split(':').map(Number);
97
- if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
265
+ const [hours, minutes] = time.split(":").map(Number);
266
+ if (isNaN(hours) ||
267
+ isNaN(minutes) ||
268
+ hours < 0 ||
269
+ hours > 23 ||
270
+ minutes < 0 ||
271
+ minutes > 59) {
98
272
  throw new Error(`Invalid time format: ${time}`);
99
273
  }
100
274
  // Validate pattern format (yyyy/mm/dd with ** wildcards)
101
275
  if (!matchPattern.match(/^\d{4}\/(\d{2}|\*\*)\/(\d{2}|\*\*)$/)) {
102
276
  throw new Error(`Invalid pattern format: ${matchPattern}`);
103
277
  }
104
- const [year, month, day] = matchPattern.split('/');
278
+ const [year, month, day] = matchPattern.split("/");
105
279
  const yearNum = parseInt(year, 10);
106
- const monthNum = month === '**' ? '*' : parseInt(month, 10);
107
- const dayNum = day === '**' ? '*' : parseInt(day, 10);
280
+ const monthNum = month === "**" ? "*" : parseInt(month, 10);
281
+ const dayNum = day === "**" ? "*" : parseInt(day, 10);
108
282
  // Validate year (basic range check, assuming 1970-2099 for cron compatibility)
109
- if (isNaN(yearNum) || yearNum < new Date().getFullYear() || yearNum > 3000) {
283
+ if (isNaN(yearNum) ||
284
+ yearNum < new Date().getFullYear() ||
285
+ yearNum > 3000) {
110
286
  throw new Error(`Invalid year in pattern: ${matchPattern}`);
111
287
  }
112
288
  // Validate month//
113
289
  //@ts-ignore
114
- if (month !== '**' && (isNaN(monthNum) || monthNum < 1 || monthNum > 12)) {
290
+ if (month !== "**" && (isNaN(monthNum) || monthNum < 1 || monthNum > 12)) {
115
291
  throw new Error(`Invalid month in pattern: ${matchPattern}`);
116
292
  }
117
293
  // Validate day
118
294
  //@ts-ignore
119
- if (day !== '**' && (isNaN(dayNum) || dayNum < 1 || dayNum > 31)) {
295
+ if (day !== "**" && (isNaN(dayNum) || dayNum < 1 || dayNum > 31)) {
120
296
  throw new Error(`Invalid day in pattern: ${matchPattern}`);
121
297
  }
122
298
  // Note: Cron doesn't support year directly, so we rely on pattern matching in schedule
@@ -130,7 +306,7 @@ class Scheduler {
130
306
  */
131
307
  isMatchingWeekDay(weekDays) {
132
308
  const currentDay = new Date().getDay();
133
- return weekDays.includes('*') || weekDays.includes(currentDay);
309
+ return weekDays.includes("*") || weekDays.includes(currentDay);
134
310
  }
135
311
  //Checks if the current date matches the specified match signalling
136
312
  //@param matchPattern Pattern (e.g., '2025/**/** ', '2025 /06 /01')
@@ -144,12 +320,14 @@ class Scheduler {
144
320
  if (!matchPattern.match(/^\d{4}\/(\d{2}|\*\*)\/(\d{2}|\*\*)$/)) {
145
321
  return false;
146
322
  }
147
- const [patternYear, patternMonth, patternDay] = matchPattern.split('/');
323
+ const [patternYear, patternMonth, patternDay] = matchPattern.split("/");
148
324
  const yearNum = parseInt(patternYear, 10);
149
- const monthNum = patternMonth === '**' ? month : parseInt(patternMonth, 10);
150
- const dayNum = patternDay === '**' ? day : parseInt(patternDay, 10);
325
+ const monthNum = patternMonth === "**" ? month : parseInt(patternMonth, 10);
326
+ const dayNum = patternDay === "**" ? day : parseInt(patternDay, 10);
151
327
  // Validate parsed values
152
- if (isNaN(yearNum) || (patternMonth !== '**' && isNaN(monthNum)) || (patternDay !== '**' && isNaN(dayNum))) {
328
+ if (isNaN(yearNum) ||
329
+ (patternMonth !== "**" && isNaN(monthNum)) ||
330
+ (patternDay !== "**" && isNaN(dayNum))) {
153
331
  return false;
154
332
  }
155
333
  // Check year match
@@ -157,17 +335,19 @@ class Scheduler {
157
335
  return false;
158
336
  }
159
337
  // Check month match
160
- if (patternMonth !== '**' && monthNum !== month) {
338
+ if (patternMonth !== "**" && monthNum !== month) {
161
339
  return false;
162
340
  }
163
341
  // Check day match
164
- if (patternDay !== '**' && dayNum !== day) {
342
+ if (patternDay !== "**" && dayNum !== day) {
165
343
  return false;
166
344
  }
167
345
  // Validate date (e.g., avoid Feb 30)
168
- if (patternMonth !== '**' && patternDay !== '**') {
346
+ if (patternMonth !== "**" && patternDay !== "**") {
169
347
  const date = new Date(yearNum, monthNum - 1, dayNum);
170
- if (date.getFullYear() !== yearNum || date.getMonth() + 1 !== monthNum || date.getDate() !== dayNum) {
348
+ if (date.getFullYear() !== yearNum ||
349
+ date.getMonth() + 1 !== monthNum ||
350
+ date.getDate() !== dayNum) {
171
351
  return false;
172
352
  }
173
353
  }
@@ -180,7 +360,7 @@ class Scheduler {
180
360
  schedule(tasks) {
181
361
  for (const task of tasks) {
182
362
  if (!task.time) {
183
- this.logHandler('No valid time provided for scheduled task, skipping');
363
+ this.logHandler("No valid time provided for scheduled task, skipping");
184
364
  continue;
185
365
  }
186
366
  // Weekdays trigger
@@ -201,7 +381,7 @@ class Scheduler {
201
381
  }
202
382
  }));
203
383
  this.cronJobs.push(cronJob);
204
- this.logHandler(`Scheduled task at ${task.time} with weekdays ${task.weekDays.join(',')}`);
384
+ this.logHandler(`Scheduled task at ${task.time} with weekdays ${task.weekDays.join(",")}`);
205
385
  }
206
386
  catch (error) {
207
387
  this.logHandler(`Error processing task at ${task.time} for weekdays: ${error}`);
@@ -280,7 +460,7 @@ class Scheduler {
280
460
  }
281
461
  }), task.interval);
282
462
  this.intervalIds.push(intervalId);
283
- this.logHandler(`Started interval task every ${task.interval / 1000} seconds with weekdays ${task.weekDays.join(',')} and patterns ${task.matchPattern ? task.matchPattern.join(',') : 'none'}`);
463
+ this.logHandler(`Started interval task every ${task.interval / 1000} seconds with weekdays ${task.weekDays.join(",")} and patterns ${task.matchPattern ? task.matchPattern.join(",") : "none"}`);
284
464
  }
285
465
  }
286
466
  /**
@@ -288,26 +468,33 @@ class Scheduler {
288
468
  * Called when PM2 sends a shutdown signal
289
469
  */
290
470
  stop() {
291
- this.cronJobs.forEach(job => job.stop());
471
+ this.cronJobs.forEach((job) => job.stop());
292
472
  this.cronJobs = [];
293
- this.logHandler('All scheduled tasks stopped');
294
- this.intervalIds.forEach(id => clearInterval(id));
473
+ this.logHandler("All scheduled tasks stopped");
474
+ this.intervalIds.forEach((id) => clearInterval(id));
295
475
  this.intervalIds = [];
296
- this.logHandler('All interval tasks stopped');
476
+ this.logHandler("All interval tasks stopped");
477
+ if (this.jobPollerInterval) {
478
+ clearInterval(this.jobPollerInterval);
479
+ this.jobPollerInterval = null;
480
+ this.logHandler("Job poller stopped");
481
+ }
482
+ this.db.close();
483
+ this.logHandler("Database connection closed");
297
484
  }
298
485
  /**
299
486
  * Sets up integration with PM2 by handling shutdown signals
300
487
  * PM2 sends SIGINT when stopping the process
301
488
  */
302
489
  setupPM2() {
303
- process.on('SIGINT', () => {
304
- this.logHandler('Received SIGINT from PM2, stopping scheduler...');
490
+ process.on("SIGINT", () => {
491
+ this.logHandler("Received SIGINT from PM2, stopping scheduler...");
305
492
  this.stop();
306
493
  process.exit(0);
307
494
  });
308
- process.on('message', (msg) => {
309
- if (msg === 'shutdown') {
310
- this.logHandler('Received shutdown message from PM2, stopping scheduler...');
495
+ process.on("message", (msg) => {
496
+ if (msg === "shutdown") {
497
+ this.logHandler("Received shutdown message from PM2, stopping scheduler...");
311
498
  this.stop();
312
499
  process.exit(0);
313
500
  }
@@ -2,12 +2,28 @@
2
2
  export type Task = () => void | Promise<void>;
3
3
  /** A log handler is a function that processes log messages */
4
4
  export type LogHandler = (message: string) => void;
5
+ /** A job handler receives data of any type and processes it */
6
+ export type JobHandler<T = any> = (data: T) => void | Promise<void>;
7
+ /** Registry of job handlers keyed by job name */
8
+ export type JobRegistry = Record<string, JobHandler>;
9
+ /** A stored job record in the database */
10
+ export interface JobRecord {
11
+ id: number;
12
+ job_name: string;
13
+ scheduled_at: string;
14
+ data: string;
15
+ status: "pending" | "processing" | "completed" | "failed";
16
+ created_at: string;
17
+ error?: string;
18
+ }
5
19
  /**
6
20
  * Configuration options for the Scheduler
7
21
  */
8
22
  export interface SchedulerConfig {
9
23
  /** Optional custom log handler. If not provided, logs are written to daily files (e.g., "2025-03-24.log") */
10
24
  logHandler?: LogHandler;
25
+ /** Path to SQLite database file for job persistence. Defaults to 'scheduler.db' in current directory */
26
+ dbPath?: string;
11
27
  }
12
28
  /**
13
29
  * Definition of a scheduled task with time, matchPattern, and weekday constraints
@@ -19,7 +35,7 @@ export interface ScheduledTask {
19
35
  /** The task to execute */
20
36
  action: Task;
21
37
  /** Array of weekdays to run on (0-6 for Sun-Sat, or '*' for all days) */
22
- weekDays: Array<number | '*'>;
38
+ weekDays: Array<number | "*">;
23
39
  }
24
40
  /**
25
41
  * Definition of an interval task with time, matchPattern, and weekday constraints
@@ -30,5 +46,5 @@ export interface IntervalTask {
30
46
  /** The task to execute */
31
47
  action: Task;
32
48
  /** Array of weekdays to run on (0-6 for Sun-Sat, or '*' for all days) */
33
- weekDays: Array<number | '*'>;
49
+ weekDays: Array<number | "*">;
34
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namnguyen.repl.it/nn-scheduler",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "A scheduler module for NodeJS",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -11,9 +11,11 @@
11
11
  },
12
12
  "keywords": [],
13
13
  "dependencies": {
14
+ "better-sqlite3": "^11.0.0",
14
15
  "node-cron": "^3.0.3"
15
16
  },
16
17
  "devDependencies": {
18
+ "@types/better-sqlite3": "^7.6.11",
17
19
  "@babel/core": "^7.26.10",
18
20
  "@babel/preset-env": "^7.26.9",
19
21
  "@babel/preset-typescript": "^7.26.0",