jw-automator 3.1.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
150
157
 
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.
158
+ **Example scenarios:**
153
159
 
154
- This feature is ideal for:
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
155
163
 
156
- * Home automation logic ("turn heater off at 9 even if offline")
157
- * Sensor sampling
158
- * Data collection pipelines
164
+ **Backwards compatibility:**
165
+
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
 
@@ -413,14 +449,15 @@ npm run test:coverage
413
449
 
414
450
  ### Top-level action fields:
415
451
 
416
- | Field | Description |
417
- | ------------ | ------------------------------------------------- |
418
- | `id` | Unique internal identifier (auto-generated) |
419
- | `name` | User label (optional) |
420
- | `cmd` | Name of registered function to execute |
421
- | `payload` | Data passed to the command |
422
- | `date` | Next scheduled run time (local `Date`) |
423
- | `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`) |
424
461
 
425
462
  ### Repeat block:
426
463
 
File without changes
package/docs/MIGRATION.md CHANGED
File without changes
File without changes
@@ -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?
File without changes
File without changes
@@ -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/index.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jw-automator",
3
- "version": "3.1.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',
@@ -173,7 +224,7 @@ class Automator {
173
224
  return 0;
174
225
  }
175
226
 
176
- const allowedUpdates = ['cmd', 'payload', 'date', 'unBuffered', 'repeat', 'count'];
227
+ const allowedUpdates = ['cmd', 'payload', 'date', 'unBuffered', 'catchUpWindow', 'repeat', 'count'];
177
228
 
178
229
  for (const action of toUpdate) {
179
230
  for (const key of allowedUpdates) {
@@ -191,6 +242,14 @@ class Automator {
191
242
  }
192
243
  }
193
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
+
194
253
  this._emit('update', {
195
254
  type: 'update',
196
255
  operation: 'update',
@@ -263,12 +322,31 @@ class Automator {
263
322
  return toRemove.length;
264
323
  }
265
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
+
266
344
  /**
267
345
  * Get all actions (deep copy)
268
346
  */
269
347
  getActions() {
270
348
  const state = this.host.getState();
271
- return JSON.parse(JSON.stringify(state.actions));
349
+ return state.actions.map(a => this._cloneAction(a));
272
350
  }
273
351
 
274
352
  /**
@@ -278,7 +356,7 @@ class Automator {
278
356
  const state = this.host.getState();
279
357
  return state.actions
280
358
  .filter(a => a.name === name)
281
- .map(a => JSON.parse(JSON.stringify(a)));
359
+ .map(a => this._cloneAction(a));
282
360
  }
283
361
 
284
362
  /**
@@ -287,7 +365,7 @@ class Automator {
287
365
  getActionByID(id) {
288
366
  const state = this.host.getState();
289
367
  const action = state.actions.find(a => a.id === id);
290
- return action ? JSON.parse(JSON.stringify(action)) : null;
368
+ return action ? this._cloneAction(action) : null;
291
369
  }
292
370
 
293
371
  /**
@@ -393,13 +471,21 @@ class Automator {
393
471
  _loadState() {
394
472
  try {
395
473
  const state = this.options.storage.load();
396
- this.host.setState(state);
397
474
 
398
- // Update nextId to be higher than any existing ID
475
+ // Normalize catchUpWindow for existing actions (for backwards compatibility)
399
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
+
400
484
  const maxId = Math.max(...state.actions.map(a => a.id || 0));
401
485
  this.nextId = maxId + 1;
402
486
  }
487
+
488
+ this.host.setState(state);
403
489
  } catch (error) {
404
490
  this._emit('error', {
405
491
  type: 'error',
@@ -439,7 +525,45 @@ class Automator {
439
525
  }
440
526
 
441
527
  /**
442
- * 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
443
567
  */
444
568
  _validateAction(action) {
445
569
  if (!action.cmd) {
@@ -448,16 +572,91 @@ class Automator {
448
572
 
449
573
  if (action.repeat) {
450
574
  const validTypes = ['second', 'minute', 'hour', 'day', 'weekday', 'weekend', 'week', 'month', 'year'];
451
- if (!validTypes.includes(action.repeat.type)) {
452
- 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';
453
584
  }
454
585
 
455
- if (action.repeat.interval !== undefined && action.repeat.interval < 1) {
456
- 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
+ }
457
598
  }
458
599
 
600
+ // Defensive: Validate dstPolicy
459
601
  if (action.repeat.dstPolicy && !['once', 'twice'].includes(action.repeat.dstPolicy)) {
460
- 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";
461
660
  }
462
661
  }
463
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
  */
File without changes
File without changes
File without changes
File without changes