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 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
- // Add an action
62
- automator.addAction({
63
- name: 'Morning Lights',
64
- cmd: 'turnLightOn',
65
- date: new Date('2025-05-01T07:00:00'),
66
- payload: null,
67
- unBuffered: false,
68
- repeat: {
69
- type: 'day',
70
- interval: 1,
71
- limit: null,
72
- endDate: null,
73
- dstPolicy: 'once'
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
- If the device is offline or delayed (e.g., blocked by CPU load):
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
- unBuffered: false // default: catch up missed executions
146
- unBuffered: true // skip missed executions
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
- For example:
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
- * A job scheduled at 09:00 will still run when the device restarts at 10:00 (if buffered).
152
- * A sequence of per-second readings will "compress" naturally after a delay.
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
- This feature is ideal for:
164
+ **Backwards compatibility:**
155
165
 
156
- * Home automation logic ("turn heater off at 9 even if offline")
157
- * Sensor sampling
158
- * Data collection pipelines
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 | Description |
408
- | ------------ | ------------------------------------------------- |
409
- | `id` | Unique internal identifier (auto-generated) |
410
- | `name` | User label (optional) |
411
- | `cmd` | Name of registered function to execute |
412
- | `payload` | Data passed to the command |
413
- | `date` | Next scheduled run time (local `Date`) |
414
- | `unBuffered` | Skip missed events (`true`) or catch up (`false`) |
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
 
@@ -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
@@ -136,6 +136,10 @@ automator.updateActionByID(id, {
136
136
  name: 'New Name',
137
137
  repeat: { type: 'hour', interval: 2 }
138
138
  });
139
+
140
+ automator.updateActionByName('Old Name', {
141
+ name: 'New Name'
142
+ });
139
143
  ```
140
144
 
141
145
  ---
@@ -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
- // Add actions
54
-
55
- // 1. Every 10 seconds - demo message
56
- automator.addAction({
57
- name: 'Demo Message',
58
- cmd: 'logMessage',
59
- date: new Date(Date.now() + 5000), // Start in 5 seconds
60
- payload: { message: 'This is a recurring message every 10 seconds' },
61
- unBuffered: false,
62
- repeat: {
63
- type: 'second',
64
- interval: 10,
65
- limit: 6, // Run 6 times then stop
66
- dstPolicy: 'once'
67
- }
68
- });
69
-
70
- // 2. Daily morning routine at 7:00 AM
71
- automator.addAction({
72
- name: 'Morning Routine',
73
- cmd: 'morningRoutine',
74
- date: new Date(new Date().setHours(7, 0, 0, 0)),
75
- unBuffered: false,
76
- repeat: {
77
- type: 'day',
78
- interval: 1,
79
- dstPolicy: 'once'
80
- }
81
- });
82
-
83
- // 3. Weekly backup every Sunday at 2:00 AM
84
- const nextSunday = new Date();
85
- nextSunday.setDate(nextSunday.getDate() + (7 - nextSunday.getDay()) % 7);
86
- nextSunday.setHours(2, 0, 0, 0);
87
-
88
- automator.addAction({
89
- name: 'Weekly Backup',
90
- cmd: 'weeklyBackup',
91
- date: nextSunday,
92
- unBuffered: false,
93
- repeat: {
94
- type: 'week',
95
- interval: 1,
96
- dstPolicy: 'once'
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jw-automator",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "A resilient, local-time, 1-second precision automation scheduler for Node.js",
5
5
  "main": "index.js",
6
6
  "files": [
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: actionSpec.date ? new Date(actionSpec.date) : new Date(),
96
- unBuffered: actionSpec.unBuffered || false,
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 JSON.parse(JSON.stringify(state.actions));
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 => JSON.parse(JSON.stringify(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 ? JSON.parse(JSON.stringify(action)) : null;
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
- // Update nextId to be higher than any existing ID
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
- * Validate action specification
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
- if (!validTypes.includes(action.repeat.type)) {
405
- throw new Error(`Invalid repeat type: ${action.repeat.type}`);
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
- if (action.repeat.interval !== undefined && action.repeat.interval < 1) {
409
- throw new Error('Repeat interval must be >= 1');
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
- throw new Error('DST policy must be "once" or "twice"');
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
  }
@@ -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
- // Determine if we should execute this occurrence
59
- // UnBuffered: only execute if this is the actual current tick (not catching up)
60
- // Buffered: execute all missed occurrences
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 shouldExecute = action.unBuffered ? isInCurrentTick : true;
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
  */