jw-automator 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,11 @@ 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(action);
196
+ }
197
+
150
198
  this._emit('update', {
151
199
  type: 'update',
152
200
  operation: 'update',
@@ -173,7 +221,7 @@ class Automator {
173
221
  return 0;
174
222
  }
175
223
 
176
- const allowedUpdates = ['cmd', 'payload', 'date', 'unBuffered', 'repeat', 'count'];
224
+ const allowedUpdates = ['cmd', 'payload', 'date', 'unBuffered', 'catchUpWindow', 'repeat', 'count'];
177
225
 
178
226
  for (const action of toUpdate) {
179
227
  for (const key of allowedUpdates) {
@@ -191,6 +239,11 @@ class Automator {
191
239
  }
192
240
  }
193
241
 
242
+ // Re-normalize catchUpWindow if unBuffered or catchUpWindow was updated
243
+ if ('unBuffered' in updates || 'catchUpWindow' in updates) {
244
+ action.catchUpWindow = this._normalizeCatchUpWindow(action);
245
+ }
246
+
194
247
  this._emit('update', {
195
248
  type: 'update',
196
249
  operation: 'update',
@@ -263,12 +316,31 @@ class Automator {
263
316
  return toRemove.length;
264
317
  }
265
318
 
319
+ /**
320
+ * Deep clone an action
321
+ */
322
+ _cloneAction(action) {
323
+ const cloned = { ...action };
324
+
325
+ // Deep copy nested objects
326
+ if (action.repeat) {
327
+ cloned.repeat = { ...action.repeat };
328
+ }
329
+
330
+ // Clone Date objects properly
331
+ if (action.date) {
332
+ cloned.date = new Date(action.date);
333
+ }
334
+
335
+ return cloned;
336
+ }
337
+
266
338
  /**
267
339
  * Get all actions (deep copy)
268
340
  */
269
341
  getActions() {
270
342
  const state = this.host.getState();
271
- return JSON.parse(JSON.stringify(state.actions));
343
+ return state.actions.map(a => this._cloneAction(a));
272
344
  }
273
345
 
274
346
  /**
@@ -278,7 +350,7 @@ class Automator {
278
350
  const state = this.host.getState();
279
351
  return state.actions
280
352
  .filter(a => a.name === name)
281
- .map(a => JSON.parse(JSON.stringify(a)));
353
+ .map(a => this._cloneAction(a));
282
354
  }
283
355
 
284
356
  /**
@@ -287,7 +359,7 @@ class Automator {
287
359
  getActionByID(id) {
288
360
  const state = this.host.getState();
289
361
  const action = state.actions.find(a => a.id === id);
290
- return action ? JSON.parse(JSON.stringify(action)) : null;
362
+ return action ? this._cloneAction(action) : null;
291
363
  }
292
364
 
293
365
  /**
@@ -393,13 +465,21 @@ class Automator {
393
465
  _loadState() {
394
466
  try {
395
467
  const state = this.options.storage.load();
396
- this.host.setState(state);
397
468
 
398
- // Update nextId to be higher than any existing ID
469
+ // Normalize catchUpWindow for existing actions (for backwards compatibility)
399
470
  if (state.actions && state.actions.length > 0) {
471
+ state.actions = state.actions.map(action => ({
472
+ ...action,
473
+ catchUpWindow: action.catchUpWindow !== undefined
474
+ ? action.catchUpWindow
475
+ : this._normalizeCatchUpWindow(action)
476
+ }));
477
+
400
478
  const maxId = Math.max(...state.actions.map(a => a.id || 0));
401
479
  this.nextId = maxId + 1;
402
480
  }
481
+
482
+ this.host.setState(state);
403
483
  } catch (error) {
404
484
  this._emit('error', {
405
485
  type: 'error',
@@ -438,8 +518,88 @@ class Automator {
438
518
  }, this.options.saveInterval);
439
519
  }
440
520
 
521
+
522
+ /**
523
+ * Calculate interval in milliseconds from a repeat spec.
524
+ * Note: Uses approximations for month/year, as this is for a default
525
+ * catch-up window, not for precise scheduling.
526
+ *
527
+ * @param {object} repeat
528
+ * @returns {number}
529
+ * @private
530
+ */
531
+ _getIntervalMilliseconds(repeat) {
532
+ const interval = repeat.interval || 1;
533
+ switch (repeat.type) {
534
+ case 'second':
535
+ return interval * 1000;
536
+ case 'minute':
537
+ return interval * 60 * 1000;
538
+ case 'hour':
539
+ return interval * 60 * 60 * 1000;
540
+ case 'day':
541
+ case 'weekday': // Approximated as 1 day for catch-up purposes
542
+ case 'weekend': // Approximated as 1 day for catch-up purposes
543
+ return interval * 24 * 60 * 60 * 1000;
544
+ case 'week':
545
+ return interval * 7 * 24 * 60 * 60 * 1000;
546
+ case 'month':
547
+ // A reasonable approximation for a default is 30 days
548
+ return interval * 30 * 24 * 60 * 60 * 1000;
549
+ case 'year':
550
+ // A reasonable approximation for a default is 365 days
551
+ return interval * 365 * 24 * 60 * 60 * 1000;
552
+ default:
553
+ // Fallback for an invalid type that slipped past validation
554
+ return 60000; // 1 minute
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Normalize catchUpWindow property (handles backwards compatibility with unBuffered)
560
+ *
561
+ * Priority:
562
+ * 1. catchUpWindow specified → normalize it (coerce Infinity to "unlimited" if needed)
563
+ * 2. unBuffered specified → convert to catchUpWindow equivalent
564
+ * 3. Neither specified → default to "unlimited" (catch up everything)
565
+ *
566
+ * @param {Object} spec - Action specification
567
+ * @returns {string|number} - Normalized catchUpWindow value ("unlimited" or milliseconds)
568
+ */
569
+ _normalizeCatchUpWindow(spec) {
570
+ // 1. New property takes precedence
571
+ if (spec.catchUpWindow !== undefined) {
572
+ // Coerce Infinity to "unlimited" (backwards compatibility)
573
+ if (spec.catchUpWindow === Infinity) {
574
+ this._emit('debug', {
575
+ type: 'debug',
576
+ message: 'Coercing catchUpWindow: Infinity → "unlimited"'
577
+ });
578
+ return "unlimited";
579
+ }
580
+ return spec.catchUpWindow;
581
+ }
582
+
583
+ // 2. Backwards compatibility mapping for legacy 'unBuffered'
584
+ if (spec.unBuffered !== undefined) {
585
+ return spec.unBuffered ? 0 : "unlimited";
586
+ }
587
+
588
+ // 3. For recurring actions, default to the interval duration
589
+ if (spec.repeat) {
590
+ return this._getIntervalMilliseconds(spec.repeat);
591
+ }
592
+
593
+ // 4. For one-time actions, default to 0 (no catch-up)
594
+ return 0;
595
+ }
596
+
441
597
  /**
442
- * Validate action specification
598
+ * Validate and normalize action specification
599
+ * Philosophy: "Fail loudly, run defensively"
600
+ * - Emit ERROR events for serious issues but coerce to reasonable defaults
601
+ * - Emit DEBUG events for auto-corrections
602
+ * - Never silently fail
443
603
  */
444
604
  _validateAction(action) {
445
605
  if (!action.cmd) {
@@ -448,16 +608,86 @@ class Automator {
448
608
 
449
609
  if (action.repeat) {
450
610
  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}`);
611
+
612
+ // CRITICAL: An invalid repeat.type is a fatal error, as intent is lost.
613
+ if (!action.repeat.type || !validTypes.includes(action.repeat.type)) {
614
+ throw new Error(`Invalid repeat.type "${action.repeat.type}". Must be one of: ${validTypes.join(', ')}`);
453
615
  }
454
616
 
455
- if (action.repeat.interval !== undefined && action.repeat.interval < 1) {
456
- throw new Error('Repeat interval must be >= 1');
617
+ // Defensive: Coerce invalid interval to Math.max(1, Math.floor(value))
618
+ if (action.repeat.interval !== undefined) {
619
+ const original = action.repeat.interval;
620
+ const coerced = Math.max(1, Math.floor(original));
621
+ if (original !== coerced) {
622
+ this._emit('warning', {
623
+ type: 'warning',
624
+ message: `Invalid repeat.interval ${original} - coerced to ${coerced}`,
625
+ actionSpec: action
626
+ });
627
+ action.repeat.interval = coerced;
628
+ }
457
629
  }
458
630
 
631
+ // Defensive: Validate dstPolicy
459
632
  if (action.repeat.dstPolicy && !['once', 'twice'].includes(action.repeat.dstPolicy)) {
460
- throw new Error('DST policy must be "once" or "twice"');
633
+ this._emit('warning', {
634
+ type: 'warning',
635
+ message: `Invalid dstPolicy "${action.repeat.dstPolicy}" - defaulting to "once"`,
636
+ actionSpec: action
637
+ });
638
+ action.repeat.dstPolicy = 'once';
639
+ }
640
+
641
+ // Defensive: Coerce invalid repeat.limit to null (unlimited) with WARNING event
642
+ if (action.repeat.limit !== undefined && action.repeat.limit !== null) {
643
+ if (typeof action.repeat.limit !== 'number' || action.repeat.limit < 1) {
644
+ this._emit('warning', {
645
+ type: 'warning',
646
+ message: `Invalid repeat.limit ${action.repeat.limit} - defaulting to null (unlimited)`,
647
+ actionSpec: action
648
+ });
649
+ action.repeat.limit = null;
650
+ }
651
+ }
652
+
653
+ // Defensive: Validate repeat.endDate
654
+ if (action.repeat.endDate !== undefined && action.repeat.endDate !== null) {
655
+ try {
656
+ new Date(action.repeat.endDate);
657
+ } catch (e) {
658
+ this._emit('warning', {
659
+ type: 'warning',
660
+ message: `Invalid repeat.endDate - ignoring`,
661
+ actionSpec: action
662
+ });
663
+ action.repeat.endDate = null;
664
+ }
665
+ }
666
+ }
667
+
668
+ // Defensive: Validate catchUpWindow if provided
669
+ if (action.catchUpWindow !== undefined) {
670
+ const isValidString = action.catchUpWindow === "unlimited";
671
+ const isNumber = typeof action.catchUpWindow === 'number';
672
+ const isInfinity = action.catchUpWindow === Infinity; // Allow for backwards compatibility
673
+
674
+ // Coerce negative numbers to 0 FIRST
675
+ if (isNumber && action.catchUpWindow < 0) {
676
+ this._emit('warning', {
677
+ type: 'warning',
678
+ message: `Negative catchUpWindow ${action.catchUpWindow} - coerced to 0`,
679
+ actionSpec: action
680
+ });
681
+ action.catchUpWindow = 0;
682
+ }
683
+ // Then validate it's a valid value
684
+ else if (!isValidString && !isNumber && !isInfinity) {
685
+ this._emit('warning', {
686
+ type: 'warning',
687
+ message: `Invalid catchUpWindow "${action.catchUpWindow}" - defaulting to "unlimited"`,
688
+ actionSpec: action
689
+ });
690
+ action.catchUpWindow = "unlimited";
461
691
  }
462
692
  }
463
693
  }
@@ -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