cronwatch 1.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.
package/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # CronWatch
2
+
3
+ Lightweight cron job tracker, debugger, and monitor for Node.js applications.
4
+
5
+ Zero dependencies. Full TypeScript support. Plugin-ready.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install cronwatch
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```js
16
+ const { createCronWatch } = require('cronwatch');
17
+
18
+ const cron = createCronWatch();
19
+
20
+ await cron.trackJob('send-emails', async () => {
21
+ // your job logic here
22
+ await sendPendingEmails();
23
+ });
24
+ ```
25
+
26
+ Every call to `trackJob` automatically captures start/end time, duration, success/failure status, error stacks, and retry counts.
27
+
28
+ ## Configuration
29
+
30
+ ```js
31
+ const cron = createCronWatch({
32
+ retries: 3, // max retry attempts (default: 0)
33
+ retryDelay: 1000, // base delay between retries in ms (default: 1000)
34
+ retryBackoff: 'exponential', // 'fixed' | 'linear' | 'exponential'
35
+ timeout: 30000, // job timeout in ms, 0 = disabled (default: 0)
36
+ logLevel: LOG_LEVELS.INFO, // DEBUG | INFO | WARN | ERROR | SILENT
37
+ storeMaxEntries: 1000, // max entries kept in memory (default: 1000)
38
+ timestamps: true, // ISO timestamps in log output (default: true)
39
+ colorize: true, // colorized console output (default: true)
40
+ });
41
+ ```
42
+
43
+ Per-job overrides:
44
+
45
+ ```js
46
+ await cron.trackJob('heavy-job', jobFn, {
47
+ retries: 5,
48
+ retryDelay: 2000,
49
+ retryBackoff: 'linear',
50
+ timeout: 60000,
51
+ });
52
+ ```
53
+
54
+ ## Structured Logs
55
+
56
+ Every tracked job produces a structured entry:
57
+
58
+ ```json
59
+ {
60
+ "jobName": "send-emails",
61
+ "status": "success",
62
+ "startTime": "2026-03-20T10:00:00.000Z",
63
+ "endTime": "2026-03-20T10:00:00.234Z",
64
+ "duration": 234,
65
+ "error": null,
66
+ "retries": 0
67
+ }
68
+ ```
69
+
70
+ On failure:
71
+
72
+ ```json
73
+ {
74
+ "jobName": "sync-inventory",
75
+ "status": "failure",
76
+ "startTime": "2026-03-20T10:00:00.000Z",
77
+ "endTime": "2026-03-20T10:00:03.512Z",
78
+ "duration": 3512,
79
+ "error": {
80
+ "message": "Connection refused",
81
+ "stack": "Error: Connection refused\n at ...",
82
+ "code": null
83
+ },
84
+ "retries": 3
85
+ }
86
+ ```
87
+
88
+ ## Querying Logs
89
+
90
+ ```js
91
+ const emailLogs = await cron.getJobLogs('send-emails');
92
+ const allLogs = await cron.getAllLogs();
93
+ await cron.clearLogs();
94
+ ```
95
+
96
+ ## Plugin System
97
+
98
+ Extend CronWatch by hooking into job lifecycle events.
99
+
100
+ ```js
101
+ cron.use({
102
+ name: 'slack-alerter',
103
+ onFailure({ jobName, error }) {
104
+ slack.send(`Job ${jobName} failed: ${error.message}`);
105
+ },
106
+ onTimeout({ jobName }) {
107
+ slack.send(`Job ${jobName} timed out!`);
108
+ },
109
+ });
110
+ ```
111
+
112
+ ### Available Hooks
113
+
114
+ | Hook | Trigger |
115
+ |------|---------|
116
+ | `onStart` | Before job executes |
117
+ | `onSuccess` | Job completed successfully |
118
+ | `onFailure` | Job failed after all retries |
119
+ | `onRetry` | Before each retry attempt |
120
+ | `onTimeout` | Job exceeded timeout |
121
+
122
+ ## Custom Store Adapter
123
+
124
+ By default, logs are kept in memory. You can plug in any backend:
125
+
126
+ ```js
127
+ const { createCronWatch, StoreAdapter } = require('cronwatch');
128
+
129
+ class MongoAdapter extends StoreAdapter {
130
+ async save(entry) { /* insert into MongoDB */ }
131
+ async getByJob(jobName) { /* query by jobName */ }
132
+ async getAll() { /* return all entries */ }
133
+ async clear() { /* drop collection */ }
134
+ }
135
+
136
+ const cron = createCronWatch({
137
+ storeAdapter: new MongoAdapter(),
138
+ });
139
+ ```
140
+
141
+ See `examples/custom-store-adapter.js` for a working file-based adapter.
142
+
143
+ ## Use with node-cron
144
+
145
+ ```js
146
+ const nodeCron = require('node-cron');
147
+ const { createCronWatch } = require('cronwatch');
148
+
149
+ const cron = createCronWatch({ retries: 2, timeout: 10000 });
150
+
151
+ nodeCron.schedule('*/5 * * * *', () => {
152
+ cron.trackJob('cleanup-temp-files', async () => {
153
+ await cleanupTempFiles();
154
+ });
155
+ });
156
+ ```
157
+
158
+ ## TypeScript
159
+
160
+ Full type definitions are included. Import and use directly:
161
+
162
+ ```ts
163
+ import { createCronWatch, CronWatchPlugin, JobEntry } from 'cronwatch';
164
+
165
+ const cron = createCronWatch({ retries: 2 });
166
+
167
+ const plugin: CronWatchPlugin = {
168
+ name: 'my-plugin',
169
+ onSuccess({ jobName }) {
170
+ console.log(`${jobName} done`);
171
+ },
172
+ };
173
+
174
+ cron.use(plugin);
175
+ ```
176
+
177
+ ## API Reference
178
+
179
+ ### `createCronWatch(options?)`
180
+
181
+ Creates a new CronWatch instance.
182
+
183
+ ### `instance.trackJob(jobName, jobFn, options?)`
184
+
185
+ Executes and tracks a job. Returns a `Promise<JobEntry>`.
186
+
187
+ ### `instance.use(plugin)`
188
+
189
+ Registers a plugin. Returns `this` for chaining.
190
+
191
+ ### `instance.getJobLogs(jobName)`
192
+
193
+ Returns all log entries for a specific job.
194
+
195
+ ### `instance.getAllLogs()`
196
+
197
+ Returns all log entries.
198
+
199
+ ### `instance.clearLogs()`
200
+
201
+ Clears the log store.
202
+
203
+ ## Architecture
204
+
205
+ ```
206
+ src/
207
+ config.js - Centralized config with defaults and merging
208
+ logger.js - Multi-level logger with pluggable outputs
209
+ store.js - In-memory store with adapter interface
210
+ retry.js - Retry engine with backoff strategies
211
+ plugin.js - Plugin lifecycle manager
212
+ tracker.js - Core trackJob orchestrator
213
+ index.js - Public API surface
214
+ types/
215
+ index.d.ts - Full TypeScript definitions
216
+ ```
217
+
218
+ ## Roadmap
219
+
220
+ - [ ] Database adapters (MongoDB, PostgreSQL, Redis)
221
+ - [ ] Dashboard API (Express/Fastify)
222
+ - [ ] Alert system (email, webhooks, Slack)
223
+ - [ ] Job scheduling (built-in cron parser)
224
+ - [ ] Metrics export (Prometheus, StatsD)
225
+
226
+ ## License
227
+
228
+ MIT
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "cronwatch",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight cron job tracker, debugger, and monitor for Node.js applications",
5
+ "main": "src/index.js",
6
+ "types": "types/index.d.ts",
7
+ "files": [
8
+ "src/",
9
+ "types/"
10
+ ],
11
+ "scripts": {
12
+ "example": "node examples/basic.js",
13
+ "example:cron": "node examples/with-node-cron.js",
14
+ "test": "node --test test/"
15
+ },
16
+ "keywords": [
17
+ "cron",
18
+ "scheduler",
19
+ "monitor",
20
+ "tracking",
21
+ "jobs",
22
+ "retry",
23
+ "logging",
24
+ "debug"
25
+ ],
26
+ "author": "Manav Dadwal",
27
+ "license": "MIT",
28
+ "engines": {
29
+ "node": ">=18.0.0"
30
+ },
31
+ "devDependencies": {},
32
+ "dependencies": {}
33
+ }
package/src/config.js ADDED
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const LOG_LEVELS = Object.freeze({
4
+ DEBUG: 0,
5
+ INFO: 1,
6
+ WARN: 2,
7
+ ERROR: 3,
8
+ SILENT: 4,
9
+ });
10
+
11
+ const DEFAULTS = Object.freeze({
12
+ retries: 0,
13
+ retryDelay: 1000,
14
+ retryBackoff: 'fixed',
15
+ timeout: 0,
16
+ logLevel: LOG_LEVELS.INFO,
17
+ storeMaxEntries: 1000,
18
+ timestamps: true,
19
+ colorize: true,
20
+ });
21
+
22
+ class Config {
23
+ #settings;
24
+
25
+ constructor(overrides = {}) {
26
+ this.#settings = { ...DEFAULTS, ...stripUndefined(overrides) };
27
+ }
28
+
29
+ get(key) {
30
+ return this.#settings[key];
31
+ }
32
+
33
+ set(key, value) {
34
+ this.#settings[key] = value;
35
+ return this;
36
+ }
37
+
38
+ merge(overrides = {}) {
39
+ return new Config({ ...this.#settings, ...stripUndefined(overrides) });
40
+ }
41
+
42
+ toJSON() {
43
+ return { ...this.#settings };
44
+ }
45
+ }
46
+
47
+ function stripUndefined(obj) {
48
+ const clean = {};
49
+ for (const [k, v] of Object.entries(obj)) {
50
+ if (v !== undefined) clean[k] = v;
51
+ }
52
+ return clean;
53
+ }
54
+
55
+ module.exports = { Config, LOG_LEVELS, DEFAULTS };
package/src/index.js ADDED
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ const { Config, LOG_LEVELS, DEFAULTS } = require('./config');
4
+ const { Logger } = require('./logger');
5
+ const { Store, StoreAdapter, MemoryAdapter } = require('./store');
6
+ const { PluginManager, HOOK_NAMES } = require('./plugin');
7
+ const { trackJob: _trackJob, TimeoutError } = require('./tracker');
8
+ const { BACKOFF_STRATEGIES } = require('./retry');
9
+
10
+ class CronWatch {
11
+ #config;
12
+ #logger;
13
+ #store;
14
+ #plugins;
15
+
16
+ constructor(options = {}) {
17
+ this.#config = new Config(options);
18
+ this.#logger = new Logger(this.#config);
19
+ this.#store = new Store(
20
+ options.storeAdapter || new MemoryAdapter(this.#config.get('storeMaxEntries'))
21
+ );
22
+ this.#plugins = new PluginManager();
23
+ }
24
+
25
+ async trackJob(jobName, jobFn, options = {}) {
26
+ return _trackJob(jobName, jobFn, options, {
27
+ logger: this.#logger,
28
+ store: this.#store,
29
+ pluginManager: this.#plugins,
30
+ config: this.#config,
31
+ });
32
+ }
33
+
34
+ use(plugin) {
35
+ this.#plugins.register(plugin);
36
+ this.#logger.debug(`Plugin registered: ${plugin.name}`);
37
+ return this;
38
+ }
39
+
40
+ async getJobLogs(jobName) {
41
+ return this.#store.getByJob(jobName);
42
+ }
43
+
44
+ async getAllLogs() {
45
+ return this.#store.getAll();
46
+ }
47
+
48
+ async clearLogs() {
49
+ await this.#store.clear();
50
+ this.#logger.debug('All logs cleared');
51
+ }
52
+
53
+ get config() {
54
+ return this.#config;
55
+ }
56
+
57
+ get logger() {
58
+ return this.#logger;
59
+ }
60
+
61
+ get plugins() {
62
+ return this.#plugins.plugins;
63
+ }
64
+ }
65
+
66
+ function createCronWatch(options = {}) {
67
+ return new CronWatch(options);
68
+ }
69
+
70
+ module.exports = {
71
+ CronWatch,
72
+ createCronWatch,
73
+ Config,
74
+ Logger,
75
+ Store,
76
+ StoreAdapter,
77
+ MemoryAdapter,
78
+ PluginManager,
79
+ TimeoutError,
80
+ LOG_LEVELS,
81
+ DEFAULTS,
82
+ HOOK_NAMES,
83
+ BACKOFF_STRATEGIES,
84
+ };
package/src/logger.js ADDED
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const { LOG_LEVELS } = require('./config');
4
+
5
+ const COLORS = {
6
+ reset: '\x1b[0m',
7
+ dim: '\x1b[2m',
8
+ green: '\x1b[32m',
9
+ yellow: '\x1b[33m',
10
+ red: '\x1b[31m',
11
+ cyan: '\x1b[36m',
12
+ magenta: '\x1b[35m',
13
+ gray: '\x1b[90m',
14
+ };
15
+
16
+ const LEVEL_TAGS = {
17
+ [LOG_LEVELS.DEBUG]: { label: 'DEBUG', color: COLORS.gray },
18
+ [LOG_LEVELS.INFO]: { label: 'INFO ', color: COLORS.cyan },
19
+ [LOG_LEVELS.WARN]: { label: 'WARN ', color: COLORS.yellow },
20
+ [LOG_LEVELS.ERROR]: { label: 'ERROR', color: COLORS.red },
21
+ };
22
+
23
+ class Logger {
24
+ #level;
25
+ #colorize;
26
+ #timestamps;
27
+ #outputs;
28
+
29
+ constructor(config) {
30
+ this.#level = config.get('logLevel');
31
+ this.#colorize = config.get('colorize');
32
+ this.#timestamps = config.get('timestamps');
33
+ this.#outputs = [consoleOutput];
34
+ }
35
+
36
+ addOutput(fn) {
37
+ if (typeof fn === 'function') this.#outputs.push(fn);
38
+ return this;
39
+ }
40
+
41
+ debug(msg, meta) { this.#log(LOG_LEVELS.DEBUG, msg, meta); }
42
+ info(msg, meta) { this.#log(LOG_LEVELS.INFO, msg, meta); }
43
+ warn(msg, meta) { this.#log(LOG_LEVELS.WARN, msg, meta); }
44
+ error(msg, meta) { this.#log(LOG_LEVELS.ERROR, msg, meta); }
45
+
46
+ #log(level, msg, meta) {
47
+ if (level < this.#level) return;
48
+
49
+ const entry = {
50
+ level,
51
+ label: LEVEL_TAGS[level]?.label || 'LOG',
52
+ timestamp: this.#timestamps ? new Date().toISOString() : null,
53
+ message: msg,
54
+ meta: meta || null,
55
+ };
56
+
57
+ const formatted = this.#format(entry);
58
+ for (const output of this.#outputs) {
59
+ output(entry, formatted);
60
+ }
61
+ }
62
+
63
+ #format(entry) {
64
+ const c = this.#colorize ? COLORS : noColors();
65
+ const tag = LEVEL_TAGS[entry.level] || { label: 'LOG', color: '' };
66
+ const color = this.#colorize ? tag.color : '';
67
+
68
+ const parts = [];
69
+ if (entry.timestamp) {
70
+ parts.push(`${c.dim}${entry.timestamp}${c.reset}`);
71
+ }
72
+ parts.push(`${color}[${entry.label}]${c.reset}`);
73
+ parts.push(`${c.magenta}[CronWatch]${c.reset}`);
74
+ parts.push(entry.message);
75
+
76
+ return parts.join(' ');
77
+ }
78
+ }
79
+
80
+ function consoleOutput(entry, formatted) {
81
+ const stream = entry.level >= LOG_LEVELS.ERROR ? console.error : console.log;
82
+ stream(formatted);
83
+ if (entry.meta) {
84
+ stream(
85
+ entry.level >= LOG_LEVELS.ERROR
86
+ ? entry.meta
87
+ : JSON.stringify(entry.meta, null, 2)
88
+ );
89
+ }
90
+ }
91
+
92
+ function noColors() {
93
+ const empty = {};
94
+ for (const key of Object.keys(COLORS)) empty[key] = '';
95
+ return empty;
96
+ }
97
+
98
+ module.exports = { Logger, LOG_LEVELS };
package/src/plugin.js ADDED
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const HOOK_NAMES = ['onStart', 'onSuccess', 'onFailure', 'onRetry', 'onTimeout'];
4
+
5
+ class PluginManager {
6
+ #plugins;
7
+
8
+ constructor() {
9
+ this.#plugins = [];
10
+ }
11
+
12
+ register(plugin) {
13
+ if (!plugin || typeof plugin !== 'object') {
14
+ throw new TypeError('Plugin must be an object');
15
+ }
16
+ if (!plugin.name || typeof plugin.name !== 'string') {
17
+ throw new TypeError('Plugin must have a string "name" property');
18
+ }
19
+
20
+ const hasHook = HOOK_NAMES.some((h) => typeof plugin[h] === 'function');
21
+ if (!hasHook) {
22
+ throw new TypeError(
23
+ `Plugin "${plugin.name}" must implement at least one hook: ${HOOK_NAMES.join(', ')}`
24
+ );
25
+ }
26
+
27
+ this.#plugins.push(plugin);
28
+ return this;
29
+ }
30
+
31
+ async emit(hookName, context) {
32
+ if (!HOOK_NAMES.includes(hookName)) return;
33
+
34
+ for (const plugin of this.#plugins) {
35
+ if (typeof plugin[hookName] === 'function') {
36
+ try {
37
+ await plugin[hookName](context);
38
+ } catch (err) {
39
+ console.error(`[CronWatch] Plugin "${plugin.name}" threw in ${hookName}:`, err);
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ get plugins() {
46
+ return this.#plugins.map((p) => p.name);
47
+ }
48
+ }
49
+
50
+ module.exports = { PluginManager, HOOK_NAMES };
package/src/retry.js ADDED
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const BACKOFF_STRATEGIES = {
4
+ fixed: (delay, _attempt) => delay,
5
+ linear: (delay, attempt) => delay * attempt,
6
+ exponential: (delay, attempt) => delay * Math.pow(2, attempt - 1),
7
+ };
8
+
9
+ function getDelay(strategy, baseDelay, attempt) {
10
+ const calc = BACKOFF_STRATEGIES[strategy] || BACKOFF_STRATEGIES.fixed;
11
+ return calc(baseDelay, attempt);
12
+ }
13
+
14
+ function sleep(ms) {
15
+ return new Promise((resolve) => setTimeout(resolve, ms));
16
+ }
17
+
18
+ async function withRetry(fn, options = {}, hooks = {}) {
19
+ const maxRetries = options.retries ?? 0;
20
+ const baseDelay = options.retryDelay ?? 1000;
21
+ const strategy = options.retryBackoff ?? 'fixed';
22
+
23
+ let lastError;
24
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
25
+ try {
26
+ return await fn();
27
+ } catch (err) {
28
+ lastError = err;
29
+
30
+ if (attempt < maxRetries) {
31
+ const delay = getDelay(strategy, baseDelay, attempt + 1);
32
+ if (hooks.onRetry) {
33
+ await hooks.onRetry({ attempt: attempt + 1, maxRetries, delay, error: err });
34
+ }
35
+ await sleep(delay);
36
+ }
37
+ }
38
+ }
39
+
40
+ throw lastError;
41
+ }
42
+
43
+ module.exports = { withRetry, BACKOFF_STRATEGIES };
package/src/store.js ADDED
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pluggable storage adapter interface.
5
+ *
6
+ * Any custom adapter must implement:
7
+ * save(entry) -> Promise<void>
8
+ * getByJob(jobName) -> Promise<Array>
9
+ * getAll() -> Promise<Array>
10
+ * clear() -> Promise<void>
11
+ */
12
+ class StoreAdapter {
13
+ async save(_entry) { throw new Error('save() not implemented'); }
14
+ async getByJob(_jobName) { throw new Error('getByJob() not implemented'); }
15
+ async getAll() { throw new Error('getAll() not implemented'); }
16
+ async clear() { throw new Error('clear() not implemented'); }
17
+ }
18
+
19
+ class MemoryAdapter extends StoreAdapter {
20
+ #entries;
21
+ #maxEntries;
22
+
23
+ constructor(maxEntries = 1000) {
24
+ super();
25
+ this.#entries = [];
26
+ this.#maxEntries = maxEntries;
27
+ }
28
+
29
+ async save(entry) {
30
+ this.#entries.push(Object.freeze({ ...entry }));
31
+ if (this.#entries.length > this.#maxEntries) {
32
+ this.#entries = this.#entries.slice(-this.#maxEntries);
33
+ }
34
+ }
35
+
36
+ async getByJob(jobName) {
37
+ return this.#entries.filter((e) => e.jobName === jobName);
38
+ }
39
+
40
+ async getAll() {
41
+ return [...this.#entries];
42
+ }
43
+
44
+ async clear() {
45
+ this.#entries = [];
46
+ }
47
+
48
+ get size() {
49
+ return this.#entries.length;
50
+ }
51
+ }
52
+
53
+ class Store {
54
+ #adapter;
55
+
56
+ constructor(adapter) {
57
+ if (adapter && !(adapter instanceof StoreAdapter)) {
58
+ throw new TypeError('Store adapter must extend StoreAdapter');
59
+ }
60
+ this.#adapter = adapter || new MemoryAdapter();
61
+ }
62
+
63
+ save(entry) { return this.#adapter.save(entry); }
64
+ getByJob(jobName) { return this.#adapter.getByJob(jobName); }
65
+ getAll() { return this.#adapter.getAll(); }
66
+ clear() { return this.#adapter.clear(); }
67
+
68
+ get adapter() { return this.#adapter; }
69
+ }
70
+
71
+ module.exports = { Store, StoreAdapter, MemoryAdapter };
package/src/tracker.js ADDED
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ const { withRetry } = require('./retry');
4
+
5
+ class TimeoutError extends Error {
6
+ constructor(jobName, ms) {
7
+ super(`Job "${jobName}" timed out after ${ms}ms`);
8
+ this.name = 'TimeoutError';
9
+ this.code = 'ERR_JOB_TIMEOUT';
10
+ }
11
+ }
12
+
13
+ function withTimeout(fn, ms, jobName) {
14
+ if (!ms || ms <= 0) return fn();
15
+
16
+ return new Promise((resolve, reject) => {
17
+ const timer = setTimeout(() => {
18
+ reject(new TimeoutError(jobName, ms));
19
+ }, ms);
20
+
21
+ fn()
22
+ .then((val) => { clearTimeout(timer); resolve(val); })
23
+ .catch((err) => { clearTimeout(timer); reject(err); });
24
+ });
25
+ }
26
+
27
+ function buildEntry(jobName, status, startTime, endTime, error, retryCount) {
28
+ return {
29
+ jobName,
30
+ status,
31
+ startTime: startTime.toISOString(),
32
+ endTime: endTime.toISOString(),
33
+ duration: endTime - startTime,
34
+ error: error
35
+ ? { message: error.message, stack: error.stack, code: error.code || null }
36
+ : null,
37
+ retries: retryCount,
38
+ };
39
+ }
40
+
41
+ async function trackJob(jobName, jobFn, opts = {}, deps) {
42
+ const { logger, store, pluginManager, config } = deps;
43
+
44
+ if (typeof jobName !== 'string' || !jobName.trim()) {
45
+ throw new TypeError('jobName must be a non-empty string');
46
+ }
47
+ if (typeof jobFn !== 'function') {
48
+ throw new TypeError('jobFn must be a function');
49
+ }
50
+
51
+ const merged = config.merge(opts);
52
+ const timeout = merged.get('timeout');
53
+ const maxRetries = merged.get('retries');
54
+
55
+ let retryCount = 0;
56
+ const startTime = new Date();
57
+
58
+ logger.info(`Starting job "${jobName}"`, maxRetries > 0 ? { maxRetries } : undefined);
59
+ await pluginManager.emit('onStart', { jobName, startTime, config: merged });
60
+
61
+ try {
62
+ const wrappedFn = () => {
63
+ const result = jobFn();
64
+ const promise = result && typeof result.then === 'function' ? result : Promise.resolve(result);
65
+ return timeout > 0 ? withTimeout(() => promise, timeout, jobName) : promise;
66
+ };
67
+
68
+ const result = await withRetry(wrappedFn, merged.toJSON(), {
69
+ onRetry: async ({ attempt, delay, error }) => {
70
+ retryCount = attempt;
71
+ logger.warn(`Retrying job "${jobName}" (${attempt}/${maxRetries}) after ${delay}ms`, {
72
+ error: error.message,
73
+ });
74
+ await pluginManager.emit('onRetry', {
75
+ jobName, attempt, maxRetries, delay, error,
76
+ });
77
+ },
78
+ });
79
+
80
+ const endTime = new Date();
81
+ const entry = buildEntry(jobName, 'success', startTime, endTime, null, retryCount);
82
+
83
+ logger.info(`Job "${jobName}" completed in ${entry.duration}ms`);
84
+ await store.save(entry);
85
+ await pluginManager.emit('onSuccess', { jobName, entry, result });
86
+
87
+ return entry;
88
+ } catch (error) {
89
+ const endTime = new Date();
90
+ const isTimeout = error.name === 'TimeoutError';
91
+ const status = isTimeout ? 'timeout' : 'failure';
92
+ const entry = buildEntry(jobName, status, startTime, endTime, error, retryCount);
93
+
94
+ if (isTimeout) {
95
+ logger.error(`Job "${jobName}" timed out after ${timeout}ms`);
96
+ await pluginManager.emit('onTimeout', { jobName, entry, error });
97
+ } else {
98
+ logger.error(`Job "${jobName}" failed after ${retryCount} retries`, error);
99
+ await pluginManager.emit('onFailure', { jobName, entry, error });
100
+ }
101
+
102
+ await store.save(entry);
103
+ return entry;
104
+ }
105
+ }
106
+
107
+ module.exports = { trackJob, TimeoutError };
@@ -0,0 +1,142 @@
1
+ export interface CronWatchOptions {
2
+ /** Max retry attempts (default: 0) */
3
+ retries?: number;
4
+ /** Base delay between retries in ms (default: 1000) */
5
+ retryDelay?: number;
6
+ /** Backoff strategy: 'fixed' | 'linear' | 'exponential' (default: 'fixed') */
7
+ retryBackoff?: 'fixed' | 'linear' | 'exponential';
8
+ /** Job timeout in ms, 0 = no timeout (default: 0) */
9
+ timeout?: number;
10
+ /** Minimum log level (default: LOG_LEVELS.INFO) */
11
+ logLevel?: number;
12
+ /** Max entries retained in memory store (default: 1000) */
13
+ storeMaxEntries?: number;
14
+ /** Include ISO timestamps in logs (default: true) */
15
+ timestamps?: boolean;
16
+ /** Colorize console output (default: true) */
17
+ colorize?: boolean;
18
+ /** Custom store adapter instance */
19
+ storeAdapter?: StoreAdapter;
20
+ }
21
+
22
+ export interface JobTrackOptions {
23
+ retries?: number;
24
+ retryDelay?: number;
25
+ retryBackoff?: 'fixed' | 'linear' | 'exponential';
26
+ timeout?: number;
27
+ }
28
+
29
+ export interface JobEntry {
30
+ jobName: string;
31
+ status: 'success' | 'failure' | 'timeout';
32
+ startTime: string;
33
+ endTime: string;
34
+ duration: number;
35
+ error: JobError | null;
36
+ retries: number;
37
+ }
38
+
39
+ export interface JobError {
40
+ message: string;
41
+ stack: string;
42
+ code: string | null;
43
+ }
44
+
45
+ export interface PluginContext {
46
+ jobName: string;
47
+ [key: string]: unknown;
48
+ }
49
+
50
+ export interface CronWatchPlugin {
51
+ name: string;
52
+ onStart?(ctx: PluginContext): void | Promise<void>;
53
+ onSuccess?(ctx: PluginContext): void | Promise<void>;
54
+ onFailure?(ctx: PluginContext): void | Promise<void>;
55
+ onRetry?(ctx: PluginContext): void | Promise<void>;
56
+ onTimeout?(ctx: PluginContext): void | Promise<void>;
57
+ }
58
+
59
+ export declare class Config {
60
+ constructor(overrides?: Partial<CronWatchOptions>);
61
+ get<K extends keyof CronWatchOptions>(key: K): CronWatchOptions[K];
62
+ set<K extends keyof CronWatchOptions>(key: K, value: CronWatchOptions[K]): this;
63
+ merge(overrides?: Partial<CronWatchOptions>): Config;
64
+ toJSON(): CronWatchOptions;
65
+ }
66
+
67
+ export declare class Logger {
68
+ constructor(config: Config);
69
+ addOutput(fn: (entry: object, formatted: string) => void): this;
70
+ debug(msg: string, meta?: unknown): void;
71
+ info(msg: string, meta?: unknown): void;
72
+ warn(msg: string, meta?: unknown): void;
73
+ error(msg: string, meta?: unknown): void;
74
+ }
75
+
76
+ export declare class StoreAdapter {
77
+ save(entry: JobEntry): Promise<void>;
78
+ getByJob(jobName: string): Promise<JobEntry[]>;
79
+ getAll(): Promise<JobEntry[]>;
80
+ clear(): Promise<void>;
81
+ }
82
+
83
+ export declare class MemoryAdapter extends StoreAdapter {
84
+ constructor(maxEntries?: number);
85
+ readonly size: number;
86
+ }
87
+
88
+ export declare class Store {
89
+ constructor(adapter?: StoreAdapter);
90
+ save(entry: JobEntry): Promise<void>;
91
+ getByJob(jobName: string): Promise<JobEntry[]>;
92
+ getAll(): Promise<JobEntry[]>;
93
+ clear(): Promise<void>;
94
+ readonly adapter: StoreAdapter;
95
+ }
96
+
97
+ export declare class PluginManager {
98
+ register(plugin: CronWatchPlugin): this;
99
+ emit(hookName: string, context: PluginContext): Promise<void>;
100
+ readonly plugins: string[];
101
+ }
102
+
103
+ export declare class TimeoutError extends Error {
104
+ readonly code: string;
105
+ constructor(jobName: string, ms: number);
106
+ }
107
+
108
+ export declare class CronWatch {
109
+ constructor(options?: CronWatchOptions);
110
+ trackJob(
111
+ jobName: string,
112
+ jobFn: () => unknown | Promise<unknown>,
113
+ options?: JobTrackOptions
114
+ ): Promise<JobEntry>;
115
+ use(plugin: CronWatchPlugin): this;
116
+ getJobLogs(jobName: string): Promise<JobEntry[]>;
117
+ getAllLogs(): Promise<JobEntry[]>;
118
+ clearLogs(): Promise<void>;
119
+ readonly config: Config;
120
+ readonly logger: Logger;
121
+ readonly plugins: string[];
122
+ }
123
+
124
+ export declare function createCronWatch(options?: CronWatchOptions): CronWatch;
125
+
126
+ export declare const LOG_LEVELS: {
127
+ readonly DEBUG: 0;
128
+ readonly INFO: 1;
129
+ readonly WARN: 2;
130
+ readonly ERROR: 3;
131
+ readonly SILENT: 4;
132
+ };
133
+
134
+ export declare const DEFAULTS: Readonly<CronWatchOptions>;
135
+
136
+ export declare const HOOK_NAMES: readonly string[];
137
+
138
+ export declare const BACKOFF_STRATEGIES: {
139
+ readonly fixed: (delay: number, attempt: number) => number;
140
+ readonly linear: (delay: number, attempt: number) => number;
141
+ readonly exponential: (delay: number, attempt: number) => number;
142
+ };