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/CHANGELOG.md +61 -0
- package/README.md +77 -38
- package/docs/ARCHITECTURE.md +8 -6
- package/docs/MIGRATION.md +68 -160
- package/docs/QUICKSTART.md +29 -7
- package/docs/defensive-defaults.md +65 -0
- package/examples/basic-example.js +48 -45
- package/examples/hello-world.js +0 -0
- package/examples/iot-sensor-example.js +0 -0
- package/examples/seed-example.js +147 -0
- package/index.js +0 -0
- package/package.json +2 -2
- package/src/Automator.js +246 -16
- package/src/core/CoreEngine.js +108 -4
- package/src/core/RecurrenceEngine.js +0 -0
- package/src/host/SchedulerHost.js +0 -0
- package/src/storage/FileStorage.js +0 -0
- package/src/storage/MemoryStorage.js +0 -0
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,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
|
|
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 =>
|
|
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 ?
|
|
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
|
-
//
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
}
|
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
|
*/
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|