just-cron 0.6.1

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 (2) hide show
  1. package/just-cron.js +329 -0
  2. package/package.json +26 -0
package/just-cron.js ADDED
@@ -0,0 +1,329 @@
1
+ // just-cron.js (improved 2025 version with simple verbose logging)
2
+ // verbose: function = logging, null/undefined/false = silent
3
+ import cron from 'node-cron';
4
+ import Database from 'better-sqlite3';
5
+ import { join } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
9
+
10
+ class JustCron {
11
+ #db;
12
+ #scheduled = new Map(); // job.id → { task: cron.Schedule, isRunning: () => boolean }
13
+ #onJobRun;
14
+ #log = () => {}; // default: silent
15
+
16
+ constructor(options = {}) {
17
+ const {
18
+ dbPath = join(process.cwd(), 'cron-jobs.db'),
19
+ timezone = 'Asia/Singapore',
20
+ jobsDir = join(process.cwd(), 'jobs'),
21
+ maxConsecutiveFailures = 5,
22
+ onJobRun = this.#defaultJobRunner.bind(this),
23
+ verbose = null, // function → log, null/undefined/false → silent
24
+ } = options;
25
+
26
+ this.dbPath = dbPath;
27
+ this.timezone = timezone;
28
+ this.jobsDir = jobsDir;
29
+ this.maxConsecutiveFailures = maxConsecutiveFailures;
30
+ this.#onJobRun = onJobRun;
31
+
32
+ // Logging setup
33
+ if (typeof verbose === 'function') {
34
+ this.#log = verbose;
35
+ } else if (verbose != null && verbose !== false) {
36
+ throw new TypeError(
37
+ 'JustCron: verbose must be a function (logging) or null/undefined/false (silent)'
38
+ );
39
+ }
40
+ // otherwise → remains () => {} (silent)
41
+
42
+ this.#db = new Database(this.dbPath, { verbose: this.#log });
43
+ this.#initializeDatabase();
44
+ }
45
+
46
+ #initializeDatabase() {
47
+ this.#db.exec(`
48
+ CREATE TABLE IF NOT EXISTS jobs (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ name TEXT UNIQUE NOT NULL,
51
+ schedule TEXT NOT NULL,
52
+ description TEXT NOT NULL,
53
+ file_path TEXT,
54
+ active INTEGER DEFAULT 1 CHECK(active IN (0,1)),
55
+ last_run TEXT,
56
+ last_run_start TEXT,
57
+ last_error TEXT,
58
+ consecutive_failures INTEGER DEFAULT 0,
59
+ created_at TEXT DEFAULT (datetime('now')),
60
+ updated_at TEXT DEFAULT (datetime('now'))
61
+ )
62
+ `);
63
+
64
+ this.#db.exec(`CREATE INDEX IF NOT EXISTS idx_name ON jobs(name)`);
65
+ this.#db.exec(`CREATE INDEX IF NOT EXISTS idx_active ON jobs(active)`);
66
+ }
67
+
68
+ #defaultJobRunner(job) {
69
+ this.#log(`──── ${new Date().toISOString()} ──── ${job.name} (fallback — no handler)`);
70
+ this.#log(` • Schedule: ${job.schedule}`);
71
+ this.#log(` • File: ${job.file_path || '(none)'}`);
72
+ this.#log(` • Description: ${job.description}`);
73
+ this.#log('────');
74
+ }
75
+
76
+ /**
77
+ * Start the cron manager: load & schedule all active jobs
78
+ * @returns {number} how many jobs were successfully scheduled
79
+ */
80
+ start() {
81
+ this.stopAll();
82
+
83
+ const rows = this.#db
84
+ .prepare('SELECT * FROM jobs WHERE active = 1')
85
+ .all();
86
+
87
+ let count = 0;
88
+
89
+ for (const row of rows) {
90
+ if (this.#scheduleSingleJob(row)) {
91
+ count++;
92
+ }
93
+ }
94
+
95
+ this.#log(`JustCron → started & scheduled ${count} active job(s)`);
96
+ return count;
97
+ }
98
+
99
+ #scheduleSingleJob(job) {
100
+ if (!cron.validate(job.schedule)) {
101
+ this.#log(`Invalid cron expression → ${job.name}: ${job.schedule}`);
102
+ return false;
103
+ }
104
+
105
+ let isRunning = false;
106
+
107
+ const task = cron.schedule(
108
+ job.schedule,
109
+ async () => {
110
+ if (isRunning) {
111
+ this.#log(`[skip] ${job.name} still running — overlap prevented`);
112
+ return;
113
+ }
114
+
115
+ isRunning = true;
116
+ const startTime = new Date().toISOString();
117
+
118
+ try {
119
+ await this.#executeJob(job);
120
+
121
+ // Success
122
+ this.#db.prepare(`
123
+ UPDATE jobs
124
+ SET last_run = datetime('now'),
125
+ last_run_start = ?,
126
+ last_error = NULL,
127
+ consecutive_failures = 0,
128
+ updated_at = datetime('now')
129
+ WHERE id = ?
130
+ `).run(startTime, job.id);
131
+
132
+ this.#log(`→ OK → ${job.name}`);
133
+ } catch (err) {
134
+ const msg = (err.message || String(err)).slice(0, 1000);
135
+
136
+ let newFailures = job.consecutive_failures + 1;
137
+ let shouldDisable = newFailures >= this.maxConsecutiveFailures;
138
+
139
+ this.#db.prepare(`
140
+ UPDATE jobs
141
+ SET last_run = datetime('now'),
142
+ last_run_start = ?,
143
+ last_error = ?,
144
+ consecutive_failures = ?,
145
+ active = CASE WHEN ? THEN 0 ELSE active END,
146
+ updated_at = datetime('now')
147
+ WHERE id = ?
148
+ `).run(
149
+ startTime,
150
+ msg,
151
+ newFailures,
152
+ shouldDisable ? 1 : 0,
153
+ job.id
154
+ );
155
+
156
+ this.#log(`→ FAILED → ${job.name} (${newFailures}/${this.maxConsecutiveFailures}) ${msg}`);
157
+
158
+ if (shouldDisable) {
159
+ this.#log(`→ Auto-disabled job ${job.name} after ${newFailures} failures`);
160
+ }
161
+ } finally {
162
+ isRunning = false;
163
+ }
164
+ },
165
+ { timezone: this.timezone }
166
+ );
167
+
168
+ this.#scheduled.set(job.id, { task, isRunning: () => isRunning });
169
+ return true;
170
+ }
171
+
172
+ async #executeJob(job) {
173
+ if (!job.file_path) {
174
+ await this.#onJobRun(job);
175
+ return;
176
+ }
177
+
178
+ const fullPath = join(this.jobsDir, job.file_path);
179
+
180
+ try {
181
+ const module = await import(fullPath);
182
+
183
+ if (typeof module.default === 'function') {
184
+ await module.default(job);
185
+ } else if (typeof module.run === 'function') {
186
+ await module.run(job);
187
+ } else {
188
+ throw new Error(`No default or .run() export found in ${job.file_path}`);
189
+ }
190
+ } catch (err) {
191
+ throw new Error(`Job module failed: ${err.message}`);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Add new job
197
+ */
198
+ addJob(name, schedule, description, file_path = null, active = true) {
199
+ if (!cron.validate(schedule)) {
200
+ this.#log(`Invalid cron pattern: ${schedule}`);
201
+ return null;
202
+ }
203
+
204
+ try {
205
+ const info = this.#db.prepare(`
206
+ INSERT INTO jobs (name, schedule, description, file_path, active)
207
+ VALUES (?, ?, ?, ?, ?)
208
+ `).run(
209
+ String(name).trim(),
210
+ String(schedule).trim(),
211
+ String(description).trim(),
212
+ file_path ? String(file_path).trim() : null,
213
+ active ? 1 : 0
214
+ );
215
+
216
+ const job = this.#db
217
+ .prepare('SELECT * FROM jobs WHERE id = ?')
218
+ .get(info.lastInsertRowid);
219
+
220
+ if (job.active) {
221
+ this.#scheduleSingleJob(job);
222
+ }
223
+
224
+ return job;
225
+ } catch (err) {
226
+ if (err.code === 'SQLITE_CONSTRAINT') {
227
+ this.#log(`Job name already exists: "${name}"`);
228
+ } else {
229
+ this.#log(`addJob failed: ${err.message}`);
230
+ }
231
+ return null;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Trigger job immediately (bypasses schedule)
237
+ */
238
+ async runNow(id) {
239
+ const job = this.#db.prepare('SELECT * FROM jobs WHERE id = ?').get(id);
240
+ if (!job) return false;
241
+
242
+ this.#log(`Manual trigger → ${job.name}`);
243
+
244
+ try {
245
+ await this.#executeJob(job);
246
+ this.#db.prepare(`
247
+ UPDATE jobs
248
+ SET last_run = datetime('now'),
249
+ last_error = NULL,
250
+ consecutive_failures = 0,
251
+ updated_at = datetime('now')
252
+ WHERE id = ?
253
+ `).run(id);
254
+ return true;
255
+ } catch (err) {
256
+ const msg = (err.message || String(err)).slice(0, 1000);
257
+ this.#log(`Manual run failed → ${job.name}: ${msg}`);
258
+
259
+ this.#db.prepare(`
260
+ UPDATE jobs
261
+ SET last_error = ?,
262
+ consecutive_failures = consecutive_failures + 1,
263
+ updated_at = datetime('now')
264
+ WHERE id = ?
265
+ `).run(msg, id);
266
+ return false;
267
+ }
268
+ }
269
+
270
+ toggleJob(id) {
271
+ const job = this.#db.prepare('SELECT * FROM jobs WHERE id = ?').get(id);
272
+ if (!job) {
273
+ this.#log(`Job not found: id = ${id}`);
274
+ return null;
275
+ }
276
+
277
+ const newActive = job.active ? 0 : 1;
278
+
279
+ this.#db.prepare(`
280
+ UPDATE jobs
281
+ SET active = ?, updated_at = datetime('now')
282
+ WHERE id = ?
283
+ `).run(newActive, id);
284
+
285
+ if (newActive) {
286
+ const updated = this.#db.prepare('SELECT * FROM jobs WHERE id = ?').get(id);
287
+ this.#scheduleSingleJob(updated);
288
+ this.#log(`Job ${job.name} enabled`);
289
+ } else {
290
+ this.stopJob(id);
291
+ this.#log(`Job ${job.name} disabled`);
292
+ }
293
+
294
+ return this.#db.prepare('SELECT * FROM jobs WHERE id = ?').get(id);
295
+ }
296
+
297
+ stopJob(id) {
298
+ const entry = this.#scheduled.get(id);
299
+ if (entry) {
300
+ entry.task.stop();
301
+ this.#scheduled.delete(id);
302
+ this.#log(`Stopped job id=${id}`);
303
+ }
304
+ }
305
+
306
+ stopAll() {
307
+ for (const entry of this.#scheduled.values()) {
308
+ entry.task.stop();
309
+ }
310
+ this.#scheduled.clear();
311
+ this.#log('All scheduled jobs stopped');
312
+ }
313
+
314
+ getAllJobs() {
315
+ return this.#db.prepare('SELECT * FROM jobs ORDER BY created_at DESC').all();
316
+ }
317
+
318
+ getActiveJobs() {
319
+ return this.#db.prepare('SELECT * FROM jobs WHERE active = 1 ORDER BY name').all();
320
+ }
321
+
322
+ close() {
323
+ this.stopAll();
324
+ this.#db.close();
325
+ this.#log('JustCron closed');
326
+ }
327
+ }
328
+
329
+ export default JustCron;
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "just-cron",
3
+ "version": "0.6.1",
4
+ "description": "a light-weight persistent cron in nodejs using sqlite3",
5
+ "keywords": [
6
+ "cron",
7
+ "just-cron",
8
+ "just",
9
+ "schedule"
10
+ ],
11
+ "homepage": "https://github.com/just-node/just-cron#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/just-node/just-cron/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/just-node/just-cron.git"
18
+ },
19
+ "license": "MIT",
20
+ "author": "littlejustnode",
21
+ "type": "module",
22
+ "main": "just-cron.js",
23
+ "scripts": {
24
+ "test": ""
25
+ }
26
+ }