jw-automator 3.0.0 → 3.2.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/CHANGELOG.md +47 -0
- package/README.md +79 -33
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/MIGRATION.md +4 -0
- package/docs/QUICKSTART.md +6 -0
- package/examples/basic-example.js +48 -45
- package/examples/seed-example.js +147 -0
- package/package.json +1 -1
- package/src/Automator.js +261 -15
- package/src/core/CoreEngine.js +108 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,53 @@ All notable changes to jw-automator will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.2.0] - 2025-11-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`seed()` Method**: New bootstrapping method that runs initialization logic only when the database is empty
|
|
13
|
+
- Solves the "Bootstrapping Problem" by safely initializing actions without resetting schedules on restart
|
|
14
|
+
- Returns `true` if seeding ran, `false` if skipped
|
|
15
|
+
- Automatically saves state after seeding
|
|
16
|
+
- Perfect for system tasks that should be added once but preserved forever
|
|
17
|
+
|
|
18
|
+
- **`catchUpWindow` Property**: Time-based validity window for missed executions (replaces binary buffered/unBuffered)
|
|
19
|
+
- `catchUpWindow: "unlimited"` - Catch up ALL missed executions (like old `unBuffered: false`)
|
|
20
|
+
- `catchUpWindow: 0` - Skip ALL missed executions (like old `unBuffered: true`)
|
|
21
|
+
- `catchUpWindow: 5000` - Hybrid: tolerate 5s lag, skip if offline for hours
|
|
22
|
+
- Solves the "Thundering Herd Problem" by preventing thousands of queued executions after long downtime
|
|
23
|
+
- Fast-forward optimization uses mathematical projection to instantly advance high-frequency tasks
|
|
24
|
+
- Uses `"unlimited"` string literal instead of `Infinity` for clean JSON serialization
|
|
25
|
+
|
|
26
|
+
- **Defensive Validation**: "Fail loudly, run defensively" philosophy
|
|
27
|
+
- Invalid `repeat.type` → defaults to `'day'` with ERROR event
|
|
28
|
+
- Invalid `repeat.interval` → coerced to `Math.max(1, Math.floor(value))` with ERROR event
|
|
29
|
+
- Invalid `repeat.limit` → defaults to `null` (unlimited) with ERROR event
|
|
30
|
+
- Invalid `repeat.endDate` → ignored with ERROR event
|
|
31
|
+
- Invalid `catchUpWindow` → defaults to `"unlimited"` with ERROR event
|
|
32
|
+
- Negative `catchUpWindow` → coerced to `0` with ERROR event
|
|
33
|
+
- Missing `date` → defaults to 5 seconds from now with DEBUG event
|
|
34
|
+
- Unregistered commands → emit DEBUG event, keep trying (never remove action)
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **Backwards Compatibility**: `unBuffered` property is now a maintained alias that maps to `catchUpWindow`
|
|
39
|
+
- `unBuffered: true` → `catchUpWindow: 0`
|
|
40
|
+
- `unBuffered: false` → `catchUpWindow: "unlimited"`
|
|
41
|
+
- `catchUpWindow: Infinity` → automatically coerced to `"unlimited"` with DEBUG event
|
|
42
|
+
- Both properties supported indefinitely with zero breaking changes
|
|
43
|
+
- `catchUpWindow` takes precedence if both are specified
|
|
44
|
+
|
|
45
|
+
### Improved
|
|
46
|
+
|
|
47
|
+
- Clean JSON serialization: `"unlimited"` is a string, no special JSON handling required
|
|
48
|
+
- Enhanced action validation with comprehensive error/debug event emissions
|
|
49
|
+
- All invalid values coerced to sensible defaults - system keeps running
|
|
50
|
+
- Updated action loading to normalize `catchUpWindow` for backwards compatibility
|
|
51
|
+
- Comprehensive test coverage for both new features and defensive validation
|
|
52
|
+
- Updated examples to demonstrate `seed()` and `catchUpWindow` usage
|
|
53
|
+
- Updated documentation with detailed usage patterns and migration guidance
|
|
54
|
+
|
|
8
55
|
## [3.0.0] - 2025-11-17
|
|
9
56
|
|
|
10
57
|
### Added (v3 Complete Rewrite)
|
package/README.md
CHANGED
|
@@ -58,20 +58,22 @@ automator.addFunction('turnLightOn', function(payload) {
|
|
|
58
58
|
console.log('Turning light on');
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
automator.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
61
|
+
// Seed initial actions (runs only on first use)
|
|
62
|
+
automator.seed((auto) => {
|
|
63
|
+
auto.addAction({
|
|
64
|
+
name: 'Morning Lights',
|
|
65
|
+
cmd: 'turnLightOn',
|
|
66
|
+
date: new Date('2025-05-01T07:00:00'),
|
|
67
|
+
payload: null,
|
|
68
|
+
catchUpWindow: 60000, // Tolerate 1 minute of lag
|
|
69
|
+
repeat: {
|
|
70
|
+
type: 'day',
|
|
71
|
+
interval: 1,
|
|
72
|
+
limit: null,
|
|
73
|
+
endDate: null,
|
|
74
|
+
dstPolicy: 'once'
|
|
75
|
+
}
|
|
76
|
+
});
|
|
75
77
|
});
|
|
76
78
|
|
|
77
79
|
// Start the scheduler
|
|
@@ -137,25 +139,35 @@ This avoids cron's silent-but-surprising behaviors.
|
|
|
137
139
|
|
|
138
140
|
---
|
|
139
141
|
|
|
140
|
-
### 4. **Resilient Offline Catch-Up**
|
|
142
|
+
### 4. **Resilient Offline Catch-Up with Time Windows**
|
|
141
143
|
|
|
142
|
-
|
|
144
|
+
When the device is offline or delayed, you can control exactly how far back to catch up using the `catchUpWindow` property:
|
|
143
145
|
|
|
144
146
|
```js
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
catchUpWindow: "unlimited" // Catch up ALL missed executions (default)
|
|
148
|
+
catchUpWindow: 0 // Skip ALL missed executions (real-time only)
|
|
149
|
+
catchUpWindow: 5000 // Catch up if missed by ≤5 seconds, skip if older
|
|
147
150
|
```
|
|
148
151
|
|
|
149
|
-
|
|
152
|
+
**How it works:**
|
|
153
|
+
|
|
154
|
+
* If an action is missed by less than `catchUpWindow` milliseconds, it executes (recovers from brief glitches)
|
|
155
|
+
* If missed by more than `catchUpWindow`, it's skipped and fast-forwarded (prevents thundering herd)
|
|
156
|
+
* The fast-forward optimization uses mathematical projection to instantly advance high-frequency tasks
|
|
157
|
+
|
|
158
|
+
**Example scenarios:**
|
|
150
159
|
|
|
151
|
-
*
|
|
152
|
-
*
|
|
160
|
+
* **Billing tasks:** `catchUpWindow: "unlimited"` - Never miss a charge
|
|
161
|
+
* **Real-time alerts:** `catchUpWindow: 0` - Only relevant "now"
|
|
162
|
+
* **Sensor readings:** `catchUpWindow: 5000` - Tolerate 5s lag, skip if system was down for hours
|
|
153
163
|
|
|
154
|
-
|
|
164
|
+
**Backwards compatibility:**
|
|
155
165
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
The legacy `unBuffered` property is still fully supported:
|
|
167
|
+
```js
|
|
168
|
+
unBuffered: false // equivalent to catchUpWindow: "unlimited"
|
|
169
|
+
unBuffered: true // equivalent to catchUpWindow: 0
|
|
170
|
+
```
|
|
159
171
|
|
|
160
172
|
---
|
|
161
173
|
|
|
@@ -213,6 +225,30 @@ Options:
|
|
|
213
225
|
|
|
214
226
|
### Methods
|
|
215
227
|
|
|
228
|
+
#### `seed(callback)`
|
|
229
|
+
Seed the automator with initial actions. Runs only when the database is empty (first use).
|
|
230
|
+
|
|
231
|
+
**Returns:** `boolean` - `true` if seeding ran, `false` if skipped
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
automator.seed((auto) => {
|
|
235
|
+
auto.addAction({
|
|
236
|
+
name: 'Daily Report',
|
|
237
|
+
cmd: 'generateReport',
|
|
238
|
+
date: new Date('2025-01-01T09:00:00'),
|
|
239
|
+
catchUpWindow: "unlimited",
|
|
240
|
+
repeat: { type: 'day', interval: 1 }
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Why use seed()?**
|
|
246
|
+
|
|
247
|
+
* Solves the bootstrapping problem: safely initialize actions without resetting the schedule on every restart
|
|
248
|
+
* Preserves user-modified schedules perfectly
|
|
249
|
+
* Runs initialization logic only once in the application lifecycle
|
|
250
|
+
* Automatically saves state after seeding
|
|
251
|
+
|
|
216
252
|
#### `start()`
|
|
217
253
|
Start the scheduler.
|
|
218
254
|
|
|
@@ -257,6 +293,15 @@ automator.updateActionByID(1, {
|
|
|
257
293
|
});
|
|
258
294
|
```
|
|
259
295
|
|
|
296
|
+
#### `updateActionByName(name, updates)`
|
|
297
|
+
Update all actions with the given name. Returns the number of actions updated.
|
|
298
|
+
|
|
299
|
+
```js
|
|
300
|
+
automator.updateActionByName('My Action', {
|
|
301
|
+
payload: { newData: 'newValue' }
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
260
305
|
#### `removeActionByID(id)`
|
|
261
306
|
Remove an action by ID.
|
|
262
307
|
|
|
@@ -404,14 +449,15 @@ npm run test:coverage
|
|
|
404
449
|
|
|
405
450
|
### Top-level action fields:
|
|
406
451
|
|
|
407
|
-
| Field
|
|
408
|
-
|
|
|
409
|
-
| `id`
|
|
410
|
-
| `name`
|
|
411
|
-
| `cmd`
|
|
412
|
-
| `payload`
|
|
413
|
-
| `date`
|
|
414
|
-
| `
|
|
452
|
+
| Field | Description |
|
|
453
|
+
| --------------- | --------------------------------------------------------------------- |
|
|
454
|
+
| `id` | Unique internal identifier (auto-generated) |
|
|
455
|
+
| `name` | User label (optional) |
|
|
456
|
+
| `cmd` | Name of registered function to execute |
|
|
457
|
+
| `payload` | Data passed to the command |
|
|
458
|
+
| `date` | Next scheduled run time (local `Date`) |
|
|
459
|
+
| `catchUpWindow` | Time window for catching up missed executions (default: `"unlimited"`, or milliseconds number) |
|
|
460
|
+
| `unBuffered` | Legacy: Skip missed events (`true`) or catch up (`false`) |
|
|
415
461
|
|
|
416
462
|
### Repeat block:
|
|
417
463
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -156,7 +156,7 @@ setTimeout(() => tick(), wait);
|
|
|
156
156
|
- Event emission
|
|
157
157
|
|
|
158
158
|
**Key APIs**:
|
|
159
|
-
- Action management: `addAction`, `updateActionByID`, `removeActionByID`, etc.
|
|
159
|
+
- Action management: `addAction`, `updateActionByID`, `updateActionByName`, `removeActionByID`, etc.
|
|
160
160
|
- Function registration: `addFunction`, `removeFunction`
|
|
161
161
|
- Introspection: `getActions`, `describeAction`, `getActionsInRange`
|
|
162
162
|
- Lifecycle: `start`, `stop`
|
package/docs/MIGRATION.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
|
@@ -269,10 +269,16 @@ const id = automator.addAction({
|
|
|
269
269
|
### Update
|
|
270
270
|
|
|
271
271
|
```javascript
|
|
272
|
+
// By ID
|
|
272
273
|
automator.updateActionByID(id, {
|
|
273
274
|
name: 'Updated Task',
|
|
274
275
|
repeat: { type: 'hour', interval: 2 }
|
|
275
276
|
});
|
|
277
|
+
|
|
278
|
+
// By name
|
|
279
|
+
automator.updateActionByName('My Task', {
|
|
280
|
+
payload: { updated: true }
|
|
281
|
+
});
|
|
276
282
|
```
|
|
277
283
|
|
|
278
284
|
### Remove
|
|
@@ -50,51 +50,54 @@ automator.on('error', (event) => {
|
|
|
50
50
|
console.error('Error:', event.message);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
nextSunday
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
53
|
+
// Seed initial actions (runs only on first use)
|
|
54
|
+
automator.seed((auto) => {
|
|
55
|
+
console.log('Seeding initial actions...\n');
|
|
56
|
+
|
|
57
|
+
// 1. Every 10 seconds - demo message
|
|
58
|
+
auto.addAction({
|
|
59
|
+
name: 'Demo Message',
|
|
60
|
+
cmd: 'logMessage',
|
|
61
|
+
date: new Date(Date.now() + 5000), // Start in 5 seconds
|
|
62
|
+
payload: { message: 'This is a recurring message every 10 seconds' },
|
|
63
|
+
catchUpWindow: 30000, // Tolerate 30 seconds of lag
|
|
64
|
+
repeat: {
|
|
65
|
+
type: 'second',
|
|
66
|
+
interval: 10,
|
|
67
|
+
limit: 6, // Run 6 times then stop
|
|
68
|
+
dstPolicy: 'once'
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 2. Daily morning routine at 7:00 AM
|
|
73
|
+
auto.addAction({
|
|
74
|
+
name: 'Morning Routine',
|
|
75
|
+
cmd: 'morningRoutine',
|
|
76
|
+
date: new Date(new Date().setHours(7, 0, 0, 0)),
|
|
77
|
+
catchUpWindow: "unlimited", // Never miss a morning routine
|
|
78
|
+
repeat: {
|
|
79
|
+
type: 'day',
|
|
80
|
+
interval: 1,
|
|
81
|
+
dstPolicy: 'once'
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// 3. Weekly backup every Sunday at 2:00 AM
|
|
86
|
+
const nextSunday = new Date();
|
|
87
|
+
nextSunday.setDate(nextSunday.getDate() + (7 - nextSunday.getDay()) % 7);
|
|
88
|
+
nextSunday.setHours(2, 0, 0, 0);
|
|
89
|
+
|
|
90
|
+
auto.addAction({
|
|
91
|
+
name: 'Weekly Backup',
|
|
92
|
+
cmd: 'weeklyBackup',
|
|
93
|
+
date: nextSunday,
|
|
94
|
+
catchUpWindow: "unlimited", // Always run backups, even if delayed
|
|
95
|
+
repeat: {
|
|
96
|
+
type: 'week',
|
|
97
|
+
interval: 1,
|
|
98
|
+
dstPolicy: 'once'
|
|
99
|
+
}
|
|
100
|
+
});
|
|
98
101
|
});
|
|
99
102
|
|
|
100
103
|
// Demonstrate simulation - what will happen in the next 24 hours?
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed Example - jw-automator v3
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates the seed() method and catchUpWindow property.
|
|
5
|
+
* This example shows how to bootstrap a persistent scheduler that:
|
|
6
|
+
* - Initializes actions only on first run
|
|
7
|
+
* - Preserves user-modified schedules on subsequent runs
|
|
8
|
+
* - Uses catchUpWindow for smart catch-up behavior
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const Automator = require('../index');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
|
|
14
|
+
// Storage file path
|
|
15
|
+
const STORAGE_FILE = './seed-example-actions.json';
|
|
16
|
+
|
|
17
|
+
// Clean up old storage file for demo purposes
|
|
18
|
+
if (fs.existsSync(STORAGE_FILE)) {
|
|
19
|
+
console.log('Note: Removing existing storage file for clean demo');
|
|
20
|
+
fs.unlinkSync(STORAGE_FILE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Create automator with file-based persistence
|
|
24
|
+
const automator = new Automator({
|
|
25
|
+
storage: Automator.storage.file(STORAGE_FILE),
|
|
26
|
+
autoSave: true,
|
|
27
|
+
saveInterval: 3000
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Register command functions
|
|
31
|
+
automator.addFunction('criticalTask', function(payload, event) {
|
|
32
|
+
console.log(`[CRITICAL] ${payload.message}`);
|
|
33
|
+
console.log(` Scheduled: ${event.scheduledTime.toLocaleTimeString()}`);
|
|
34
|
+
console.log(` Executed: ${event.actualTime.toLocaleTimeString()}`);
|
|
35
|
+
console.log(` Execution #${event.count + 1}`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
automator.addFunction('realtimeAlert', function(payload, event) {
|
|
39
|
+
console.log(`[REALTIME] ${payload.message}`);
|
|
40
|
+
console.log(` Only executes if "now" - skips if missed`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
automator.addFunction('flexibleTask', function(payload, event) {
|
|
44
|
+
const lag = event.actualTime - event.scheduledTime;
|
|
45
|
+
console.log(`[FLEXIBLE] ${payload.message}`);
|
|
46
|
+
console.log(` Lag: ${lag}ms (tolerates up to 5 seconds)`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Listen to events
|
|
50
|
+
automator.on('ready', () => {
|
|
51
|
+
console.log('\n=== Automator Ready ===');
|
|
52
|
+
console.log(`Actions loaded: ${automator.getActions().length}`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// SEED: Initialize actions (runs only on first use)
|
|
56
|
+
const didSeed = automator.seed((auto) => {
|
|
57
|
+
console.log('\n=== SEEDING: First Run Detected ===');
|
|
58
|
+
console.log('Creating initial system tasks...\n');
|
|
59
|
+
|
|
60
|
+
// Task 1: Critical billing task - NEVER miss an execution
|
|
61
|
+
auto.addAction({
|
|
62
|
+
name: 'Billing Task',
|
|
63
|
+
cmd: 'criticalTask',
|
|
64
|
+
date: new Date(Date.now() + 2000),
|
|
65
|
+
payload: { message: 'Process billing (catchUpWindow: "unlimited")' },
|
|
66
|
+
catchUpWindow: "unlimited", // Catch up ALL missed executions
|
|
67
|
+
repeat: {
|
|
68
|
+
type: 'second',
|
|
69
|
+
interval: 10,
|
|
70
|
+
limit: 3
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Task 2: Real-time alert - only relevant "now"
|
|
75
|
+
auto.addAction({
|
|
76
|
+
name: 'Realtime Alert',
|
|
77
|
+
cmd: 'realtimeAlert',
|
|
78
|
+
date: new Date(Date.now() + 5000),
|
|
79
|
+
payload: { message: 'Temperature spike detected!' },
|
|
80
|
+
catchUpWindow: 0, // Skip ALL missed executions
|
|
81
|
+
repeat: {
|
|
82
|
+
type: 'second',
|
|
83
|
+
interval: 15,
|
|
84
|
+
limit: 3
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Task 3: Flexible sensor reading - tolerate brief lag
|
|
89
|
+
auto.addAction({
|
|
90
|
+
name: 'Sensor Reading',
|
|
91
|
+
cmd: 'flexibleTask',
|
|
92
|
+
date: new Date(Date.now() + 8000),
|
|
93
|
+
payload: { message: 'Read temperature sensor' },
|
|
94
|
+
catchUpWindow: 5000, // Tolerate up to 5 seconds of lag
|
|
95
|
+
repeat: {
|
|
96
|
+
type: 'second',
|
|
97
|
+
interval: 12,
|
|
98
|
+
limit: 3
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
console.log('✅ Initial tasks created!\n');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (didSeed) {
|
|
106
|
+
console.log('✨ Seeding completed - tasks initialized for the first time');
|
|
107
|
+
} else {
|
|
108
|
+
console.log('⏭️ Seeding skipped - existing schedule preserved');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log('\n=== Current Schedule ===');
|
|
112
|
+
automator.getActions().forEach((action) => {
|
|
113
|
+
console.log(`\n${action.name}:`);
|
|
114
|
+
console.log(` catchUpWindow: ${action.catchUpWindow === "unlimited" ? '"unlimited"' : action.catchUpWindow + 'ms'}`);
|
|
115
|
+
console.log(` Next run: ${action.date.toLocaleTimeString()}`);
|
|
116
|
+
console.log(` Executions so far: ${action.count}`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
console.log('\n=== Starting Scheduler ===\n');
|
|
120
|
+
automator.start();
|
|
121
|
+
|
|
122
|
+
// Simulate a brief delay after 15 seconds to demonstrate catch-up behavior
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
console.log('\n⏸️ Simulating 3-second system pause...\n');
|
|
125
|
+
const start = Date.now();
|
|
126
|
+
while (Date.now() - start < 3000) {
|
|
127
|
+
// Block the event loop
|
|
128
|
+
}
|
|
129
|
+
console.log('▶️ Resumed! Watch how different catchUpWindow values behave:\n');
|
|
130
|
+
}, 15000);
|
|
131
|
+
|
|
132
|
+
// Auto-stop after 40 seconds
|
|
133
|
+
setTimeout(() => {
|
|
134
|
+
console.log('\n=== Shutting Down ===');
|
|
135
|
+
automator.stop();
|
|
136
|
+
console.log('\n💡 Tip: Run this example again to see seeding skipped!');
|
|
137
|
+
console.log(`Storage file: ${STORAGE_FILE}`);
|
|
138
|
+
console.log('Goodbye!\n');
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}, 40000);
|
|
141
|
+
|
|
142
|
+
// Graceful shutdown on Ctrl+C
|
|
143
|
+
process.on('SIGINT', () => {
|
|
144
|
+
console.log('\n\nShutting down...');
|
|
145
|
+
automator.stop();
|
|
146
|
+
process.exit(0);
|
|
147
|
+
});
|
package/package.json
CHANGED
package/src/Automator.js
CHANGED
|
@@ -35,6 +35,33 @@ class Automator {
|
|
|
35
35
|
this._loadState();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Seed the automator with initial actions (runs only on first use)
|
|
40
|
+
*
|
|
41
|
+
* @param {Function} callback - Function to execute when database is empty
|
|
42
|
+
* @returns {boolean} - True if seeding ran, false if skipped
|
|
43
|
+
*/
|
|
44
|
+
seed(callback) {
|
|
45
|
+
if (typeof callback !== 'function') {
|
|
46
|
+
throw new Error('seed() requires a callback function');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const state = this.host.getState();
|
|
50
|
+
|
|
51
|
+
// Check if already populated
|
|
52
|
+
if (state.actions && state.actions.length > 0) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Execute seeding callback
|
|
57
|
+
callback(this);
|
|
58
|
+
|
|
59
|
+
// Immediately save the seeded state
|
|
60
|
+
this._saveState();
|
|
61
|
+
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
/**
|
|
39
66
|
* Start the automator
|
|
40
67
|
*/
|
|
@@ -83,17 +110,33 @@ class Automator {
|
|
|
83
110
|
* Add an action
|
|
84
111
|
*/
|
|
85
112
|
addAction(actionSpec) {
|
|
86
|
-
// Validate action
|
|
113
|
+
// Validate action (includes defensive coercion)
|
|
87
114
|
this._validateAction(actionSpec);
|
|
88
115
|
|
|
116
|
+
// Normalize catchUpWindow (handles backwards compatibility with unBuffered)
|
|
117
|
+
const catchUpWindow = this._normalizeCatchUpWindow(actionSpec);
|
|
118
|
+
|
|
119
|
+
// Defensive: Default missing date to 5 seconds from now
|
|
120
|
+
let startDate;
|
|
121
|
+
if (!actionSpec.date) {
|
|
122
|
+
startDate = new Date(Date.now() + 5000);
|
|
123
|
+
this._emit('debug', {
|
|
124
|
+
type: 'debug',
|
|
125
|
+
message: 'No date provided - defaulting to 5 seconds from now'
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
startDate = new Date(actionSpec.date);
|
|
129
|
+
}
|
|
130
|
+
|
|
89
131
|
// Create action with state
|
|
90
132
|
const action = {
|
|
91
133
|
id: this.nextId++,
|
|
92
134
|
name: actionSpec.name || null,
|
|
93
135
|
cmd: actionSpec.cmd,
|
|
94
136
|
payload: actionSpec.payload !== undefined ? actionSpec.payload : null,
|
|
95
|
-
date:
|
|
96
|
-
unBuffered: actionSpec.unBuffered
|
|
137
|
+
date: startDate,
|
|
138
|
+
unBuffered: actionSpec.unBuffered !== undefined ? actionSpec.unBuffered : false,
|
|
139
|
+
catchUpWindow: catchUpWindow,
|
|
97
140
|
repeat: actionSpec.repeat ? { ...actionSpec.repeat } : null,
|
|
98
141
|
count: 0
|
|
99
142
|
};
|
|
@@ -130,7 +173,7 @@ class Automator {
|
|
|
130
173
|
}
|
|
131
174
|
|
|
132
175
|
// Update allowed fields
|
|
133
|
-
const allowedUpdates = ['name', 'cmd', 'payload', 'date', 'unBuffered', 'repeat', 'count'];
|
|
176
|
+
const allowedUpdates = ['name', 'cmd', 'payload', 'date', 'unBuffered', 'catchUpWindow', 'repeat', 'count'];
|
|
134
177
|
|
|
135
178
|
for (const key of allowedUpdates) {
|
|
136
179
|
if (key in updates) {
|
|
@@ -147,6 +190,14 @@ class Automator {
|
|
|
147
190
|
}
|
|
148
191
|
}
|
|
149
192
|
|
|
193
|
+
// Re-normalize catchUpWindow if unBuffered or catchUpWindow was updated
|
|
194
|
+
if ('unBuffered' in updates || 'catchUpWindow' in updates) {
|
|
195
|
+
action.catchUpWindow = this._normalizeCatchUpWindow({
|
|
196
|
+
catchUpWindow: action.catchUpWindow,
|
|
197
|
+
unBuffered: action.unBuffered
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
150
201
|
this._emit('update', {
|
|
151
202
|
type: 'update',
|
|
152
203
|
operation: 'update',
|
|
@@ -159,6 +210,61 @@ class Automator {
|
|
|
159
210
|
}
|
|
160
211
|
}
|
|
161
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Update actions by name
|
|
215
|
+
*/
|
|
216
|
+
updateActionByName(name, updates) {
|
|
217
|
+
const state = this.host.getState();
|
|
218
|
+
const toUpdate = state.actions.filter(a => a.name === name);
|
|
219
|
+
|
|
220
|
+
if (toUpdate.length === 0) {
|
|
221
|
+
// For consistency with removeActionByName, we could throw an error.
|
|
222
|
+
// However, it might be more convenient to simply return 0.
|
|
223
|
+
// Let's return 0 for now.
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const allowedUpdates = ['cmd', 'payload', 'date', 'unBuffered', 'catchUpWindow', 'repeat', 'count'];
|
|
228
|
+
|
|
229
|
+
for (const action of toUpdate) {
|
|
230
|
+
for (const key of allowedUpdates) {
|
|
231
|
+
if (key in updates) {
|
|
232
|
+
if (key === 'date' && updates[key]) {
|
|
233
|
+
action[key] = new Date(updates[key]);
|
|
234
|
+
} else if (key === 'repeat' && updates[key]) {
|
|
235
|
+
action[key] = { ...action.repeat, ...updates[key] };
|
|
236
|
+
if (!action[key].dstPolicy) {
|
|
237
|
+
action[key].dstPolicy = 'once';
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
action[key] = updates[key];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Re-normalize catchUpWindow if unBuffered or catchUpWindow was updated
|
|
246
|
+
if ('unBuffered' in updates || 'catchUpWindow' in updates) {
|
|
247
|
+
action.catchUpWindow = this._normalizeCatchUpWindow({
|
|
248
|
+
catchUpWindow: action.catchUpWindow,
|
|
249
|
+
unBuffered: action.unBuffered
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this._emit('update', {
|
|
254
|
+
type: 'update',
|
|
255
|
+
operation: 'update',
|
|
256
|
+
actionId: action.id,
|
|
257
|
+
action: { ...action }
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (this.options.autoSave) {
|
|
262
|
+
this._saveState();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return toUpdate.length;
|
|
266
|
+
}
|
|
267
|
+
|
|
162
268
|
/**
|
|
163
269
|
* Remove action by ID
|
|
164
270
|
*/
|
|
@@ -216,12 +322,31 @@ class Automator {
|
|
|
216
322
|
return toRemove.length;
|
|
217
323
|
}
|
|
218
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Deep clone an action
|
|
327
|
+
*/
|
|
328
|
+
_cloneAction(action) {
|
|
329
|
+
const cloned = { ...action };
|
|
330
|
+
|
|
331
|
+
// Deep copy nested objects
|
|
332
|
+
if (action.repeat) {
|
|
333
|
+
cloned.repeat = { ...action.repeat };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Clone Date objects properly
|
|
337
|
+
if (action.date) {
|
|
338
|
+
cloned.date = new Date(action.date);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return cloned;
|
|
342
|
+
}
|
|
343
|
+
|
|
219
344
|
/**
|
|
220
345
|
* Get all actions (deep copy)
|
|
221
346
|
*/
|
|
222
347
|
getActions() {
|
|
223
348
|
const state = this.host.getState();
|
|
224
|
-
return
|
|
349
|
+
return state.actions.map(a => this._cloneAction(a));
|
|
225
350
|
}
|
|
226
351
|
|
|
227
352
|
/**
|
|
@@ -231,7 +356,7 @@ class Automator {
|
|
|
231
356
|
const state = this.host.getState();
|
|
232
357
|
return state.actions
|
|
233
358
|
.filter(a => a.name === name)
|
|
234
|
-
.map(a =>
|
|
359
|
+
.map(a => this._cloneAction(a));
|
|
235
360
|
}
|
|
236
361
|
|
|
237
362
|
/**
|
|
@@ -240,7 +365,7 @@ class Automator {
|
|
|
240
365
|
getActionByID(id) {
|
|
241
366
|
const state = this.host.getState();
|
|
242
367
|
const action = state.actions.find(a => a.id === id);
|
|
243
|
-
return action ?
|
|
368
|
+
return action ? this._cloneAction(action) : null;
|
|
244
369
|
}
|
|
245
370
|
|
|
246
371
|
/**
|
|
@@ -346,13 +471,21 @@ class Automator {
|
|
|
346
471
|
_loadState() {
|
|
347
472
|
try {
|
|
348
473
|
const state = this.options.storage.load();
|
|
349
|
-
this.host.setState(state);
|
|
350
474
|
|
|
351
|
-
//
|
|
475
|
+
// Normalize catchUpWindow for existing actions (for backwards compatibility)
|
|
352
476
|
if (state.actions && state.actions.length > 0) {
|
|
477
|
+
state.actions = state.actions.map(action => ({
|
|
478
|
+
...action,
|
|
479
|
+
catchUpWindow: action.catchUpWindow !== undefined
|
|
480
|
+
? action.catchUpWindow
|
|
481
|
+
: this._normalizeCatchUpWindow(action)
|
|
482
|
+
}));
|
|
483
|
+
|
|
353
484
|
const maxId = Math.max(...state.actions.map(a => a.id || 0));
|
|
354
485
|
this.nextId = maxId + 1;
|
|
355
486
|
}
|
|
487
|
+
|
|
488
|
+
this.host.setState(state);
|
|
356
489
|
} catch (error) {
|
|
357
490
|
this._emit('error', {
|
|
358
491
|
type: 'error',
|
|
@@ -392,7 +525,45 @@ class Automator {
|
|
|
392
525
|
}
|
|
393
526
|
|
|
394
527
|
/**
|
|
395
|
-
*
|
|
528
|
+
* Normalize catchUpWindow property (handles backwards compatibility with unBuffered)
|
|
529
|
+
*
|
|
530
|
+
* Priority:
|
|
531
|
+
* 1. catchUpWindow specified → normalize it (coerce Infinity to "unlimited" if needed)
|
|
532
|
+
* 2. unBuffered specified → convert to catchUpWindow equivalent
|
|
533
|
+
* 3. Neither specified → default to "unlimited" (catch up everything)
|
|
534
|
+
*
|
|
535
|
+
* @param {Object} spec - Action specification
|
|
536
|
+
* @returns {string|number} - Normalized catchUpWindow value ("unlimited" or milliseconds)
|
|
537
|
+
*/
|
|
538
|
+
_normalizeCatchUpWindow(spec) {
|
|
539
|
+
// New property takes precedence
|
|
540
|
+
if (spec.catchUpWindow !== undefined) {
|
|
541
|
+
// Coerce Infinity to "unlimited" (backwards compatibility)
|
|
542
|
+
if (spec.catchUpWindow === Infinity) {
|
|
543
|
+
this._emit('debug', {
|
|
544
|
+
type: 'debug',
|
|
545
|
+
message: 'Coercing catchUpWindow: Infinity → "unlimited"'
|
|
546
|
+
});
|
|
547
|
+
return "unlimited";
|
|
548
|
+
}
|
|
549
|
+
return spec.catchUpWindow;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Backwards compatibility mapping
|
|
553
|
+
if (spec.unBuffered !== undefined) {
|
|
554
|
+
return spec.unBuffered ? 0 : "unlimited";
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Default: catch up everything (current buffered behavior)
|
|
558
|
+
return "unlimited";
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Validate and normalize action specification
|
|
563
|
+
* Philosophy: "Fail loudly, run defensively"
|
|
564
|
+
* - Emit ERROR events for serious issues but coerce to reasonable defaults
|
|
565
|
+
* - Emit DEBUG events for auto-corrections
|
|
566
|
+
* - Never silently fail
|
|
396
567
|
*/
|
|
397
568
|
_validateAction(action) {
|
|
398
569
|
if (!action.cmd) {
|
|
@@ -401,16 +572,91 @@ class Automator {
|
|
|
401
572
|
|
|
402
573
|
if (action.repeat) {
|
|
403
574
|
const validTypes = ['second', 'minute', 'hour', 'day', 'weekday', 'weekend', 'week', 'month', 'year'];
|
|
404
|
-
|
|
405
|
-
|
|
575
|
+
|
|
576
|
+
// Defensive: Coerce invalid repeat.type to 'day' with ERROR event
|
|
577
|
+
if (!action.repeat.type || !validTypes.includes(action.repeat.type)) {
|
|
578
|
+
this._emit('error', {
|
|
579
|
+
type: 'error',
|
|
580
|
+
message: `Invalid repeat.type "${action.repeat.type}" - defaulting to "day"`,
|
|
581
|
+
actionSpec: action
|
|
582
|
+
});
|
|
583
|
+
action.repeat.type = 'day';
|
|
406
584
|
}
|
|
407
585
|
|
|
408
|
-
|
|
409
|
-
|
|
586
|
+
// Defensive: Coerce invalid interval to Math.max(1, Math.floor(value))
|
|
587
|
+
if (action.repeat.interval !== undefined) {
|
|
588
|
+
const original = action.repeat.interval;
|
|
589
|
+
const coerced = Math.max(1, Math.floor(original));
|
|
590
|
+
if (original !== coerced) {
|
|
591
|
+
this._emit('error', {
|
|
592
|
+
type: 'error',
|
|
593
|
+
message: `Invalid repeat.interval ${original} - coerced to ${coerced}`,
|
|
594
|
+
actionSpec: action
|
|
595
|
+
});
|
|
596
|
+
action.repeat.interval = coerced;
|
|
597
|
+
}
|
|
410
598
|
}
|
|
411
599
|
|
|
600
|
+
// Defensive: Validate dstPolicy
|
|
412
601
|
if (action.repeat.dstPolicy && !['once', 'twice'].includes(action.repeat.dstPolicy)) {
|
|
413
|
-
|
|
602
|
+
this._emit('error', {
|
|
603
|
+
type: 'error',
|
|
604
|
+
message: `Invalid dstPolicy "${action.repeat.dstPolicy}" - defaulting to "once"`,
|
|
605
|
+
actionSpec: action
|
|
606
|
+
});
|
|
607
|
+
action.repeat.dstPolicy = 'once';
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Defensive: Coerce invalid repeat.limit to null (unlimited) with ERROR event
|
|
611
|
+
if (action.repeat.limit !== undefined && action.repeat.limit !== null) {
|
|
612
|
+
if (typeof action.repeat.limit !== 'number' || action.repeat.limit < 1) {
|
|
613
|
+
this._emit('error', {
|
|
614
|
+
type: 'error',
|
|
615
|
+
message: `Invalid repeat.limit ${action.repeat.limit} - defaulting to null (unlimited)`,
|
|
616
|
+
actionSpec: action
|
|
617
|
+
});
|
|
618
|
+
action.repeat.limit = null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Defensive: Validate repeat.endDate
|
|
623
|
+
if (action.repeat.endDate !== undefined && action.repeat.endDate !== null) {
|
|
624
|
+
try {
|
|
625
|
+
new Date(action.repeat.endDate);
|
|
626
|
+
} catch (e) {
|
|
627
|
+
this._emit('error', {
|
|
628
|
+
type: 'error',
|
|
629
|
+
message: `Invalid repeat.endDate - ignoring`,
|
|
630
|
+
actionSpec: action
|
|
631
|
+
});
|
|
632
|
+
action.repeat.endDate = null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Defensive: Validate catchUpWindow if provided
|
|
638
|
+
if (action.catchUpWindow !== undefined) {
|
|
639
|
+
const isValidString = action.catchUpWindow === "unlimited";
|
|
640
|
+
const isNumber = typeof action.catchUpWindow === 'number';
|
|
641
|
+
const isInfinity = action.catchUpWindow === Infinity; // Allow for backwards compatibility
|
|
642
|
+
|
|
643
|
+
// Coerce negative numbers to 0 FIRST
|
|
644
|
+
if (isNumber && action.catchUpWindow < 0) {
|
|
645
|
+
this._emit('error', {
|
|
646
|
+
type: 'error',
|
|
647
|
+
message: `Negative catchUpWindow ${action.catchUpWindow} - coerced to 0`,
|
|
648
|
+
actionSpec: action
|
|
649
|
+
});
|
|
650
|
+
action.catchUpWindow = 0;
|
|
651
|
+
}
|
|
652
|
+
// Then validate it's a valid value
|
|
653
|
+
else if (!isValidString && !isNumber && !isInfinity) {
|
|
654
|
+
this._emit('error', {
|
|
655
|
+
type: 'error',
|
|
656
|
+
message: `Invalid catchUpWindow "${action.catchUpWindow}" - defaulting to "unlimited"`,
|
|
657
|
+
actionSpec: action
|
|
658
|
+
});
|
|
659
|
+
action.catchUpWindow = "unlimited";
|
|
414
660
|
}
|
|
415
661
|
}
|
|
416
662
|
}
|
package/src/core/CoreEngine.js
CHANGED
|
@@ -52,14 +52,47 @@ class CoreEngine {
|
|
|
52
52
|
let iterationCount = 0;
|
|
53
53
|
let currentNextRun = nextRun;
|
|
54
54
|
|
|
55
|
+
// Get catchUpWindow (defaults to "unlimited" for backwards compatibility)
|
|
56
|
+
const catchUpWindow = action.catchUpWindow !== undefined ? action.catchUpWindow : "unlimited";
|
|
57
|
+
|
|
58
|
+
// Fast-forward optimization: if we're far outside the catch-up window,
|
|
59
|
+
// jump directly to near the current time using mathematical projection
|
|
60
|
+
if (catchUpWindow !== "unlimited" && action.repeat) {
|
|
61
|
+
const lag = now.getTime() - currentNextRun.getTime();
|
|
62
|
+
|
|
63
|
+
if (lag > catchUpWindow * 2) {
|
|
64
|
+
// We're deep in the "Dead Zone" - use fast-forward
|
|
65
|
+
const fastForwardResult = this._fastForwardAction(action, now, catchUpWindow);
|
|
66
|
+
|
|
67
|
+
if (fastForwardResult) {
|
|
68
|
+
currentNextRun = fastForwardResult.nextRun;
|
|
69
|
+
action.date = currentNextRun;
|
|
70
|
+
action.count = fastForwardResult.count;
|
|
71
|
+
|
|
72
|
+
// After fast-forward, if still outside window, just advance without executing
|
|
73
|
+
if (currentNextRun <= now) {
|
|
74
|
+
const finalLag = now.getTime() - currentNextRun.getTime();
|
|
75
|
+
if (finalLag > catchUpWindow) {
|
|
76
|
+
// Still outside window after fast-forward, skip to next viable occurrence
|
|
77
|
+
this._advanceAction(action);
|
|
78
|
+
currentNextRun = action.date ? new Date(action.date) : null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
while (currentNextRun <= now && iterationCount < maxIterations) {
|
|
56
86
|
iterationCount++;
|
|
57
87
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
88
|
+
const lag = now.getTime() - currentNextRun.getTime();
|
|
89
|
+
|
|
90
|
+
// Determine if we should execute this occurrence based on catchUpWindow
|
|
61
91
|
const isInCurrentTick = currentNextRun > lastTick && currentNextRun <= now;
|
|
62
|
-
const
|
|
92
|
+
const isWithinWindow = catchUpWindow === "unlimited" || lag <= catchUpWindow;
|
|
93
|
+
|
|
94
|
+
// Legacy unBuffered behavior (maps to catchUpWindow: 0 or Infinity)
|
|
95
|
+
const shouldExecute = isWithinWindow;
|
|
63
96
|
|
|
64
97
|
if (shouldExecute) {
|
|
65
98
|
const event = this._executeAction(action, currentNextRun);
|
|
@@ -168,6 +201,77 @@ class CoreEngine {
|
|
|
168
201
|
}
|
|
169
202
|
}
|
|
170
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Fast-forward an action to near the current time using mathematical projection
|
|
206
|
+
* This avoids iterating through thousands/millions of occurrences for high-frequency tasks
|
|
207
|
+
*
|
|
208
|
+
* @param {Object} action - The action to fast-forward
|
|
209
|
+
* @param {Date} now - Current time
|
|
210
|
+
* @param {number} catchUpWindow - The catch-up window in milliseconds
|
|
211
|
+
* @returns {Object|null} - { nextRun, count } or null if not applicable
|
|
212
|
+
*/
|
|
213
|
+
static _fastForwardAction(action, now, catchUpWindow) {
|
|
214
|
+
if (!action.repeat || !action.date) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { type, interval = 1 } = action.repeat;
|
|
219
|
+
const currentTime = new Date(action.date);
|
|
220
|
+
const lag = now.getTime() - currentTime.getTime();
|
|
221
|
+
|
|
222
|
+
// Only applicable for simple time-based recurrence (not weekday/weekend)
|
|
223
|
+
let stepMilliseconds = 0;
|
|
224
|
+
|
|
225
|
+
switch (type) {
|
|
226
|
+
case 'second':
|
|
227
|
+
stepMilliseconds = interval * 1000;
|
|
228
|
+
break;
|
|
229
|
+
case 'minute':
|
|
230
|
+
stepMilliseconds = interval * 60 * 1000;
|
|
231
|
+
break;
|
|
232
|
+
case 'hour':
|
|
233
|
+
stepMilliseconds = interval * 60 * 60 * 1000;
|
|
234
|
+
break;
|
|
235
|
+
case 'day':
|
|
236
|
+
stepMilliseconds = interval * 24 * 60 * 60 * 1000;
|
|
237
|
+
break;
|
|
238
|
+
case 'week':
|
|
239
|
+
stepMilliseconds = interval * 7 * 24 * 60 * 60 * 1000;
|
|
240
|
+
break;
|
|
241
|
+
default:
|
|
242
|
+
// Complex recurrence (weekday, weekend, month, year) - can't fast-forward easily
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (stepMilliseconds === 0) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Calculate how many steps we can skip
|
|
251
|
+
// We want to jump to just before the catch-up window starts
|
|
252
|
+
const targetLag = catchUpWindow;
|
|
253
|
+
const timeToSkip = lag - targetLag;
|
|
254
|
+
|
|
255
|
+
if (timeToSkip <= 0) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const stepsToSkip = Math.floor(timeToSkip / stepMilliseconds);
|
|
260
|
+
|
|
261
|
+
if (stepsToSkip <= 0) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Project forward
|
|
266
|
+
const newTime = new Date(currentTime.getTime() + (stepsToSkip * stepMilliseconds));
|
|
267
|
+
const newCount = (action.count || 0) + stepsToSkip;
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
nextRun: newTime,
|
|
271
|
+
count: newCount
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
171
275
|
/**
|
|
172
276
|
* Simulate actions in a time range without mutating state
|
|
173
277
|
*/
|