@stonyx/cron 0.2.1-beta.0 → 0.2.1-beta.2

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.
@@ -0,0 +1,215 @@
1
+ # Architecture & Core Components
2
+
3
+ ## Core Components Deep Dive
4
+
5
+ ### Cron Class (`stonyx-cron/src/main.js`)
6
+
7
+ **Properties:**
8
+ ```javascript
9
+ jobs = {}; // Object mapping job keys to job objects
10
+ heap = new MinHeap(); // Priority queue of jobs
11
+ timer = null; // setTimeout handle for next scheduled run
12
+ ```
13
+
14
+ **Job Object Schema:**
15
+ ```javascript
16
+ {
17
+ key: string, // Unique identifier for the job
18
+ callback: Function, // Async function to execute
19
+ interval: number, // Interval in seconds
20
+ nextTrigger: number // Unix timestamp in seconds when job should run
21
+ }
22
+ ```
23
+
24
+ **Public Methods:**
25
+
26
+ **`register(key, callback, interval, runOnInit=false)`**
27
+ - Registers a new recurring job
28
+ - `key` (string): Unique job identifier
29
+ - `callback` (Function): Async function to execute on each trigger
30
+ - `interval` (number): Time in seconds between executions
31
+ - `runOnInit` (boolean): Whether to run callback immediately
32
+
33
+ **`unregister(key)`**
34
+ - Removes a job from the scheduler
35
+ - Deletes from `jobs` object and removes from heap
36
+ - Reschedules next run after removal
37
+
38
+ **`scheduleNextRun()`**
39
+ - Clears existing timer
40
+ - Peeks at next job in heap
41
+ - Calculates delay: `(nextTrigger - now) * 1000` (converts seconds to milliseconds)
42
+ - Sets setTimeout for next job execution
43
+
44
+ **`runDueJobs()`**
45
+ - Processes all jobs with `nextTrigger <= now`
46
+ - Executes job callbacks with error handling
47
+ - Reschedules each job after execution
48
+ - Calls `scheduleNextRun()` when done
49
+
50
+ **`setNextTrigger(job)`**
51
+ - Updates job's `nextTrigger` to `now + interval`
52
+ - Uses `getTimestamp()` which returns **seconds**, not milliseconds
53
+
54
+ **`log(text, key=null)`**
55
+ - Conditional logging based on `config.cron?.log`
56
+ - Formats messages as `Cron::${key} - ${text}:` or `Cron - ${text}:`
57
+
58
+ ### MinHeap Class (`stonyx-cron/src/min-heap.js`)
59
+
60
+ **Properties:**
61
+ ```javascript
62
+ items = []; // Array backing the heap
63
+ ```
64
+
65
+ **Public Methods:**
66
+
67
+ **`push(job)`**
68
+ - Adds job to end of array
69
+ - Bubbles up to maintain heap property
70
+
71
+ **`pop()`**
72
+ - Removes and returns root (minimum `nextTrigger`)
73
+ - Replaces root with last item
74
+ - Bubbles down to restore heap property
75
+
76
+ **`peek()`**
77
+ - Returns root without removing it
78
+ - O(1) access to next job to run
79
+
80
+ **`remove(job)`**
81
+ - Finds job by reference equality (`indexOf`)
82
+ - Replaces with last item
83
+ - Bubbles both up and down to restore heap property
84
+
85
+ **`isEmpty()`**
86
+ - Returns `true` if `items.length === 0`
87
+
88
+ **Internal Methods:**
89
+
90
+ **`bubbleUp()`**
91
+ - Moves last item up tree until heap property is satisfied
92
+ - Compares `nextTrigger` values with parent
93
+
94
+ **`bubbleDown()`**
95
+ - Moves root down tree, swapping with smallest child
96
+ - Maintains min-heap property
97
+
98
+ ---
99
+
100
+ ## Dependencies & Integration
101
+
102
+ ### Stonyx Framework Integration
103
+ ```javascript
104
+ import config from 'stonyx/config'; // Configuration system
105
+ import log from 'stonyx/log'; // Logging system
106
+ import { setupIntegrationTests } from "stonyx/test-helpers"; // Test utilities
107
+ ```
108
+
109
+ ### External Dependencies
110
+ ```javascript
111
+ import { getTimestamp } from "@stonyx/utils/date"; // Time utilities
112
+ import QUnit from 'qunit'; // Test framework
113
+ import sinon from 'sinon'; // Test spies/stubs/fake timers
114
+ ```
115
+
116
+ ### Critical Time Handling Detail
117
+ **`getTimestamp()` returns Unix timestamps in SECONDS, not milliseconds.**
118
+
119
+ This affects:
120
+ - Job `interval` values (specified in seconds)
121
+ - Job `nextTrigger` values (stored in seconds)
122
+ - Delay calculation in `scheduleNextRun()`: must multiply by 1000 for `setTimeout`
123
+
124
+ ```javascript
125
+ // CORRECT: Convert seconds to milliseconds for setTimeout
126
+ const delay = Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000;
127
+ this.timer = setTimeout(() => this.runDueJobs(), delay);
128
+ ```
129
+
130
+ ---
131
+
132
+ ## Code Patterns & Conventions
133
+
134
+ ### Module System
135
+ All modules use ES Module syntax with default exports:
136
+ ```javascript
137
+ export default class Cron { ... }
138
+ export default class MinHeap { ... }
139
+ export default { log: true, logColor: '#888' }; // config
140
+ ```
141
+
142
+ Imports use full package paths:
143
+ ```javascript
144
+ import Cron from '@stonyx/cron';
145
+ import MinHeap from '@stonyx/cron/min-heap';
146
+ ```
147
+
148
+ ### Logging Patterns
149
+ **Always check config before logging:**
150
+ ```javascript
151
+ if (config.debug) this.log('job has been triggered', job.key);
152
+ if (config.cron?.log) log.cron(`${tag} - ${text}:`);
153
+ ```
154
+
155
+ **Use appropriate log methods:**
156
+ - `log.cron()` for informational cron messages
157
+ - `log.error()` for error conditions
158
+
159
+ ### Error Handling
160
+ **Never let errors crash the scheduler:**
161
+ ```javascript
162
+ try {
163
+ await job.callback();
164
+ } catch (err) {
165
+ log.error(`Cron job "${job.key}" failed:`, err);
166
+ }
167
+ // Always reschedule job, even after error
168
+ this.setNextTrigger(job);
169
+ heap.push(job);
170
+ ```
171
+
172
+ ### Time Handling
173
+ **Always use `getTimestamp()` for current time:**
174
+ ```javascript
175
+ // CORRECT
176
+ const now = getTimestamp();
177
+ job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
178
+
179
+ // WRONG - don't use Date.now() or other time sources
180
+ const now = Date.now(); // WRONG: milliseconds instead of seconds
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Configuration Reference
186
+
187
+ ### Module Configuration
188
+ **File:** `stonyx-cron/config/environment.js`
189
+
190
+ ```javascript
191
+ const { CRON_LOG } = process;
192
+
193
+ export default {
194
+ log: CRON_LOG ?? true, // Enable/disable cron logging
195
+ logColor: '#888', // Color for cron logs
196
+ }
197
+ ```
198
+
199
+ **Environment Variable:**
200
+ - `CRON_LOG`: Set to `false` to disable cron logging
201
+
202
+ ### Using Configuration in Code
203
+ ```javascript
204
+ import config from 'stonyx/config';
205
+
206
+ // Access cron config
207
+ if (config.cron?.log) {
208
+ log.cron('message');
209
+ }
210
+
211
+ // Access debug flag (from main Stonyx config)
212
+ if (config.debug) {
213
+ this.log('debug message');
214
+ }
215
+ ```
@@ -0,0 +1,291 @@
1
+ # Extension Guide
2
+
3
+ ## Extension Points
4
+
5
+ This section provides specific guidance for common feature additions.
6
+
7
+ ### Adding Job Metadata
8
+
9
+ **Where:** `stonyx-cron/src/main.js`
10
+
11
+ **Pattern:**
12
+ ```javascript
13
+ // In register method, extend job object:
14
+ register(key, callback, interval, runOnInit=false, metadata={}) {
15
+ const job = {
16
+ callback,
17
+ interval,
18
+ key,
19
+ metadata // Add metadata field
20
+ };
21
+ this.jobs[key] = job;
22
+ // ... rest of method
23
+ }
24
+
25
+ // Access metadata in runDueJobs or other methods:
26
+ const { metadata } = job;
27
+ if (metadata.priority === 'high') {
28
+ // Handle high priority jobs differently
29
+ }
30
+ ```
31
+
32
+ **Tests to add:** Verify metadata is stored and accessible in job object.
33
+
34
+ ### Adding Priority Levels
35
+
36
+ **Where:**
37
+ - `stonyx-cron/src/main.js` (Cron class)
38
+ - `stonyx-cron/src/min-heap.js` (MinHeap comparison)
39
+
40
+ **Pattern:**
41
+ ```javascript
42
+ // Option 1: Secondary sort on priority
43
+ // In min-heap.js bubbleUp/bubbleDown, change comparison:
44
+ if (this.items[idx].nextTrigger < this.items[parentIdx].nextTrigger ||
45
+ (this.items[idx].nextTrigger === this.items[parentIdx].nextTrigger &&
46
+ this.items[idx].priority > this.items[parentIdx].priority)) {
47
+ // Swap
48
+ }
49
+
50
+ // Option 2: Offset nextTrigger by priority
51
+ // In main.js setNextTrigger:
52
+ setNextTrigger(job) {
53
+ const priorityOffset = job.priority || 0;
54
+ job.nextTrigger = getTimestamp() + parseInt(job.interval, 10) - priorityOffset;
55
+ }
56
+ ```
57
+
58
+ **Considerations:**
59
+ - Option 1 maintains exact timing but adds complexity to heap
60
+ - Option 2 is simpler but slightly alters execution timing
61
+ - Add `priority` field to job object schema
62
+
63
+ **Tests to add:** Jobs with higher priority run before lower priority when due at same time.
64
+
65
+ ### Adding Job Statistics
66
+
67
+ **Where:** `stonyx-cron/src/main.js`
68
+
69
+ **Pattern:**
70
+ ```javascript
71
+ // Extend job object with stats:
72
+ register(key, callback, interval, runOnInit=false) {
73
+ const job = {
74
+ callback,
75
+ interval,
76
+ key,
77
+ stats: {
78
+ runCount: 0,
79
+ lastRun: null,
80
+ lastError: null,
81
+ errorCount: 0
82
+ }
83
+ };
84
+ // ...
85
+ }
86
+
87
+ // Update stats in runDueJobs:
88
+ async runDueJobs() {
89
+ // ...
90
+ try {
91
+ await job.callback();
92
+ job.stats.runCount++;
93
+ job.stats.lastRun = getTimestamp();
94
+ } catch (err) {
95
+ job.stats.errorCount++;
96
+ job.stats.lastError = err.message;
97
+ log.error(`Cron job "${job.key}" failed:`, err);
98
+ }
99
+ // ...
100
+ }
101
+
102
+ // Add method to retrieve stats:
103
+ getJobStats(key) {
104
+ return this.jobs[key]?.stats;
105
+ }
106
+ ```
107
+
108
+ **Tests to add:** Verify stats increment correctly on success/error.
109
+
110
+ ### Adding Persistence
111
+
112
+ **Where:** `stonyx-cron/src/main.js`
113
+
114
+ **Pattern:**
115
+ ```javascript
116
+ // Add save/load methods:
117
+ async saveJobs() {
118
+ const jobData = Object.values(this.jobs).map(job => ({
119
+ key: job.key,
120
+ interval: job.interval,
121
+ // Don't save callback - must be re-registered
122
+ }));
123
+ // Write to file/database
124
+ }
125
+
126
+ async loadJobs(callbacks) {
127
+ const jobData = // Read from file/database
128
+ jobData.forEach(({ key, interval }) => {
129
+ if (callbacks[key]) {
130
+ this.register(key, callbacks[key], interval);
131
+ }
132
+ });
133
+ }
134
+ ```
135
+
136
+ **Considerations:**
137
+ - Cannot serialize callbacks - must be re-registered on load
138
+ - Save job metadata, intervals, and keys
139
+ - Consider saving `nextTrigger` to maintain schedule across restarts
140
+
141
+ **Tests to add:** Verify jobs can be saved and restored with same intervals.
142
+
143
+ ### Adding One-Time Jobs
144
+
145
+ **Where:** `stonyx-cron/src/main.js`
146
+
147
+ **Pattern:**
148
+ ```javascript
149
+ // Add oneTime flag to job schema:
150
+ register(key, callback, interval, runOnInit=false, oneTime=false) {
151
+ const job = { callback, interval, key, oneTime };
152
+ // ...
153
+ }
154
+
155
+ // Modify runDueJobs to unregister one-time jobs:
156
+ async runDueJobs() {
157
+ const now = getTimestamp();
158
+ const { heap } = this;
159
+
160
+ while (!heap.isEmpty() && heap.peek().nextTrigger <= now) {
161
+ const job = heap.pop();
162
+
163
+ try {
164
+ await job.callback();
165
+ } catch (err) {
166
+ log.error(`Cron job "${job.key}" failed:`, err);
167
+ }
168
+
169
+ if (job.oneTime) {
170
+ delete this.jobs[job.key]; // Don't reschedule
171
+ if (config.debug) this.log('one-time job completed', job.key);
172
+ } else {
173
+ this.setNextTrigger(job);
174
+ heap.push(job);
175
+ }
176
+ }
177
+
178
+ this.scheduleNextRun();
179
+ }
180
+ ```
181
+
182
+ **Tests to add:** One-time jobs execute once and are not rescheduled.
183
+
184
+ ---
185
+
186
+ ## Common Pitfalls & Gotchas
187
+
188
+ ### Time Units Confusion
189
+ **Pitfall:** Mixing seconds and milliseconds
190
+
191
+ **Correct:**
192
+ ```javascript
193
+ // getTimestamp() returns SECONDS
194
+ const now = getTimestamp();
195
+ job.interval = 5; // 5 seconds
196
+ job.nextTrigger = now + 5; // 5 seconds from now
197
+
198
+ // setTimeout expects MILLISECONDS
199
+ const delay = (job.nextTrigger - now) * 1000;
200
+ setTimeout(callback, delay);
201
+ ```
202
+
203
+ **Wrong:**
204
+ ```javascript
205
+ // DON'T DO THIS
206
+ const delay = job.nextTrigger - getTimestamp(); // Missing * 1000
207
+ setTimeout(callback, delay); // Will run almost immediately!
208
+ ```
209
+
210
+ ### Singleton Behavior
211
+ **Pitfall:** Creating multiple Cron instances unexpectedly
212
+
213
+ **Behavior:**
214
+ ```javascript
215
+ const cron1 = new Cron();
216
+ const cron2 = new Cron();
217
+ console.log(cron1 === cron2); // true - same instance!
218
+ ```
219
+
220
+ **Implication:** Registering jobs on any instance affects the same scheduler.
221
+
222
+ ### Job Callback Async Handling
223
+ **Pitfall:** Not awaiting async callbacks
224
+
225
+ **Correct:**
226
+ ```javascript
227
+ cron.register('job', async () => {
228
+ await someAsyncOperation();
229
+ }, 10);
230
+ ```
231
+
232
+ The scheduler awaits the callback, so errors are caught properly.
233
+
234
+ **Wrong:**
235
+ ```javascript
236
+ cron.register('job', () => {
237
+ someAsyncOperation(); // Not awaited - errors won't be caught!
238
+ }, 10);
239
+ ```
240
+
241
+ ### Heap Reference Equality
242
+ **Pitfall:** Modifying job objects outside the scheduler
243
+
244
+ **Issue:**
245
+ ```javascript
246
+ const job = cron.jobs['myJob'];
247
+ job.interval = 20; // This doesn't update the heap!
248
+ ```
249
+
250
+ **Solution:** Always use `unregister` then `register` to update job properties.
251
+
252
+ ### Test Isolation
253
+ **Pitfall:** Tests interfering with each other due to singleton
254
+
255
+ **Solution:**
256
+ ```javascript
257
+ hooks.beforeEach(function () {
258
+ clock = sinon.useFakeTimers({ shouldAdvanceTime: false });
259
+ cron = new Cron(); // Gets singleton
260
+
261
+ // Clear previous test's jobs
262
+ Object.keys(cron.jobs).forEach(key => cron.unregister(key));
263
+ });
264
+
265
+ hooks.afterEach(function () {
266
+ sinon.restore(); // Restores real timers
267
+ });
268
+ ```
269
+
270
+ ---
271
+
272
+ ## Future Enhancement Opportunities
273
+
274
+ Ideas aligned with current architecture:
275
+
276
+ 1. **Job Prioritization** - Add priority levels for jobs due at same time
277
+ 2. **Persistence Layer** - Save/restore jobs across restarts
278
+ 3. **Job Statistics** - Track run counts, errors, execution time
279
+ 4. **Job Dependencies** - Wait for other jobs before running
280
+ 5. **Cron Expression Support** - Use cron syntax instead of intervals
281
+ 6. **Job Timeout** - Cancel jobs that run too long
282
+ 7. **Pause/Resume** - Temporarily stop/start the scheduler
283
+ 8. **Job Groups** - Batch operations on related jobs
284
+ 9. **Event Emitters** - Emit events on job lifecycle (start, complete, error)
285
+ 10. **Rate Limiting** - Limit concurrent job execution
286
+
287
+ All enhancements should maintain:
288
+ - Zero-crash guarantee (catch all errors)
289
+ - O(log n) scheduling efficiency
290
+ - Singleton pattern
291
+ - Configuration-driven logging
@@ -0,0 +1,53 @@
1
+ # Improvement Opportunities
2
+
3
+ ## `register()` does not validate duplicate keys
4
+
5
+ **File:** `src/main.js`, `register()` method (line 67)
6
+
7
+ When `register()` is called with a key that already exists, it overwrites the entry in `this.jobs[key]` but never removes the old job object from the heap. The old entry remains orphaned in the heap and will still trigger when its `nextTrigger` time arrives, even though it is no longer tracked in `this.jobs`.
8
+
9
+ ```javascript
10
+ register(key, callback, interval, runOnInit=false) {
11
+ const job = { callback, interval, key };
12
+ this.jobs[key] = job; // overwrites old reference
13
+ this.setNextTrigger(job);
14
+ this.heap.push(job); // pushes new entry, old entry still in heap
15
+ // ...
16
+ }
17
+ ```
18
+
19
+ **Impact:** The orphaned heap entry will fire its old callback on the old schedule. Since it is no longer in `this.jobs`, it cannot be unregistered.
20
+
21
+ **Suggested fix:** Check for an existing key and call `unregister(key)` before registering, or throw an error if the key is already registered.
22
+
23
+ ---
24
+
25
+ ## `runOnInit` callback is not awaited
26
+
27
+ **File:** `src/main.js`, `register()` method (line 79)
28
+
29
+ When `runOnInit` is `true`, the callback is invoked synchronously without `await`:
30
+
31
+ ```javascript
32
+ if (runOnInit) {
33
+ try {
34
+ callback(); // not awaited
35
+ } catch (err) {
36
+ log.error(`Cron job "${key}" failed on init:`, err);
37
+ }
38
+ }
39
+ ```
40
+
41
+ This is inconsistent with `runDueJobs()`, which does await the callback:
42
+
43
+ ```javascript
44
+ try {
45
+ await job.callback(); // awaited
46
+ } catch (err) {
47
+ log.error(`Cron job "${job.key}" failed:`, err);
48
+ }
49
+ ```
50
+
51
+ **Impact:** If the callback is async and throws, the rejection will not be caught by the `try/catch` block in `register()`. The error becomes an unhandled promise rejection instead of being logged.
52
+
53
+ **Suggested fix:** Add `await` to the `callback()` call in the `runOnInit` branch and make `register()` async, or wrap in a `.catch()` handler.