jw-automator 4.0.1 → 6.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.
@@ -11,7 +11,7 @@ class CoreEngine {
11
11
  /**
12
12
  * Process one scheduling step
13
13
  *
14
- * @param {Object} state - Current scheduler state (actions array)
14
+ * @param {Object} state - Current scheduler state (tasks array)
15
15
  * @param {Date} lastTick - Previous tick time
16
16
  * @param {Date} now - Current tick time
17
17
  * @param {number} maxIterations - Safety limit for catch-up loops
@@ -21,29 +21,29 @@ class CoreEngine {
21
21
  const events = [];
22
22
  const newState = this._cloneState(state);
23
23
 
24
- // Process each action
25
- for (let i = 0; i < newState.actions.length; i++) {
26
- const action = newState.actions[i];
24
+ // Process each task
25
+ for (let i = 0; i < newState.tasks.length; i++) {
26
+ const task = newState.tasks[i];
27
27
 
28
- if (!action.date) {
28
+ if (!task.date) {
29
29
  // No scheduled time - run immediately
30
- const event = this._executeAction(action, now);
30
+ const event = this._executeTask(task, now);
31
31
  events.push(event);
32
32
 
33
33
  // Advance to next occurrence
34
- this._advanceAction(action);
34
+ this._advanceTask(task);
35
35
 
36
- // Check if action should be removed
37
- if (RecurrenceEngine.shouldStop(action)) {
38
- newState.actions.splice(i, 1);
36
+ // Check if task should be removed
37
+ if (RecurrenceEngine.shouldStop(task)) {
38
+ newState.tasks.splice(i, 1);
39
39
  i--;
40
40
  }
41
41
  continue;
42
42
  }
43
43
 
44
- const nextRun = new Date(action.date);
44
+ const nextRun = new Date(task.date);
45
45
 
46
- // Safety: prevent processing actions in the future
46
+ // Safety: prevent processing tasks in the future
47
47
  if (nextRun > now) {
48
48
  continue;
49
49
  }
@@ -52,78 +52,81 @@ 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
- }
55
+ // Get catchUpWindow and catchUpLimit (defaults to 0 - real-time mode, no catch-up)
56
+ const catchUpWindow = task.catchUpWindow !== undefined ? task.catchUpWindow : 0;
57
+ const catchUpLimit = task.catchUpLimit !== undefined ? task.catchUpLimit : 0;
58
+
59
+ // JUMP: Fast-forward to the beginning of the catch-up window.
60
+ // This avoids iterating through a large number of occurrences outside the window.
61
+ if (catchUpWindow !== "unlimited" && catchUpWindow > 0 && task.repeat) {
62
+ const windowStart = new Date(now.getTime() - catchUpWindow);
63
+ if (currentNextRun < windowStart) {
64
+ const ffResult = RecurrenceEngine.fastForward(currentNextRun, task.repeat, windowStart);
65
+ if (ffResult && ffResult.skippedOccurrences > 0) {
66
+ task.date = ffResult.nextRun;
67
+ task.count = (task.count || 0) + ffResult.skippedOccurrences;
68
+ currentNextRun = ffResult.nextRun;
81
69
  }
82
70
  }
83
71
  }
84
72
 
85
- while (currentNextRun <= now && iterationCount < maxIterations) {
73
+ // Buffer for collecting eligible slots when catchUpLimit is set
74
+ // This allows us to keep only the most recent N slots
75
+ const eligibleBuffer = [];
76
+ const needsBuffering = catchUpLimit !== "all" && catchUpLimit > 0;
77
+
78
+ while (currentNextRun && currentNextRun <= now && iterationCount < maxIterations) {
86
79
  iterationCount++;
87
80
 
88
81
  const lag = now.getTime() - currentNextRun.getTime();
89
82
 
90
- // Determine if we should execute this occurrence based on catchUpWindow
91
- const isInCurrentTick = currentNextRun > lastTick && currentNextRun <= now;
83
+ // Determine if this occurrence is eligible based on catchUpWindow
92
84
  const isWithinWindow = catchUpWindow === "unlimited" || lag <= catchUpWindow;
93
85
 
94
- // Legacy unBuffered behavior (maps to catchUpWindow: 0 or Infinity)
95
- const shouldExecute = isWithinWindow;
96
-
97
- if (shouldExecute) {
98
- const event = this._executeAction(action, currentNextRun);
99
- events.push(event);
86
+ if (isWithinWindow) {
87
+ if (needsBuffering) {
88
+ // Buffer this eligible slot
89
+ eligibleBuffer.push({
90
+ scheduledTime: new Date(currentNextRun),
91
+ count: task.count || 0
92
+ });
93
+ // Keep only the last N slots
94
+ if (eligibleBuffer.length > catchUpLimit) {
95
+ eligibleBuffer.shift();
96
+ }
97
+ } else if (catchUpLimit === "all") {
98
+ // Execute immediately - no limit
99
+ const event = this._executeTask(task, currentNextRun);
100
+ events.push(event);
101
+ }
102
+ // If catchUpLimit is 0, don't execute (real-time mode)
100
103
  }
101
104
 
102
105
  // Advance to next occurrence
103
106
  const prevTime = currentNextRun.getTime();
104
- this._advanceAction(action);
107
+ this._advanceTask(task);
105
108
 
106
- // Check if action should stop
107
- if (RecurrenceEngine.shouldStop(action)) {
108
- newState.actions.splice(i, 1);
109
+ // Check if task should stop
110
+ if (RecurrenceEngine.shouldStop(task)) {
111
+ newState.tasks.splice(i, 1);
109
112
  i--;
110
113
  break;
111
114
  }
112
115
 
113
116
  // Update currentNextRun for next iteration
114
- if (action.date) {
115
- currentNextRun = new Date(action.date);
117
+ if (task.date) {
118
+ currentNextRun = new Date(task.date);
116
119
 
117
120
  // SAFETY: Ensure monotonic progression
118
121
  if (currentNextRun.getTime() <= prevTime) {
119
122
  events.push({
120
123
  type: 'error',
121
- message: `Action ${action.id} failed to advance time monotonically`,
122
- actionId: action.id
124
+ message: `Task ${task.id} failed to advance time monotonically`,
125
+ taskId: task.id
123
126
  });
124
127
  // Force advancement
125
128
  currentNextRun = new Date(prevTime + 1000);
126
- action.date = currentNextRun;
129
+ task.date = currentNextRun;
127
130
  }
128
131
  } else {
129
132
  // No more occurrences
@@ -131,12 +134,29 @@ class CoreEngine {
131
134
  }
132
135
  }
133
136
 
137
+ // Execute buffered slots (if we were buffering)
138
+ if (needsBuffering && eligibleBuffer.length > 0) {
139
+ for (const slot of eligibleBuffer) {
140
+ const event = {
141
+ type: 'task',
142
+ taskId: task.id,
143
+ name: task.name,
144
+ cmd: task.cmd,
145
+ payload: task.payload,
146
+ scheduledTime: slot.scheduledTime,
147
+ actualTime: new Date(),
148
+ count: slot.count
149
+ };
150
+ events.push(event);
151
+ }
152
+ }
153
+
134
154
  // Safety check: iteration limit reached
135
155
  if (iterationCount >= maxIterations) {
136
156
  events.push({
137
157
  type: 'error',
138
- message: `Action ${action.id} exceeded max iterations (${maxIterations}) - possible infinite loop`,
139
- actionId: action.id
158
+ message: `Task ${task.id} exceeded max iterations (${maxIterations}) - possible infinite loop`,
159
+ taskId: task.id
140
160
  });
141
161
  }
142
162
  }
@@ -149,137 +169,61 @@ class CoreEngine {
149
169
  */
150
170
  static _cloneState(state) {
151
171
  return {
152
- actions: state.actions.map(action => ({ ...action }))
172
+ tasks: state.tasks.map(task => ({ ...task }))
153
173
  };
154
174
  }
155
175
 
156
176
  /**
157
- * Check if a time falls within the current tick window
158
- */
159
- static _isCurrentTick(time, lastTick, now) {
160
- return time > lastTick && time <= now;
161
- }
162
-
163
- /**
164
- * Create an action execution event
177
+ * Create a task execution event
165
178
  */
166
- static _executeAction(action, scheduledTime) {
179
+ static _executeTask(task, scheduledTime) {
167
180
  return {
168
- type: 'action',
169
- actionId: action.id,
170
- name: action.name,
171
- cmd: action.cmd,
172
- payload: action.payload,
181
+ type: 'task',
182
+ taskId: task.id,
183
+ name: task.name,
184
+ cmd: task.cmd,
185
+ payload: task.payload,
173
186
  scheduledTime: new Date(scheduledTime),
174
187
  actualTime: new Date(),
175
- count: action.count || 0
188
+ count: task.count || 0
176
189
  };
177
190
  }
178
191
 
179
192
  /**
180
- * Advance an action to its next occurrence
193
+ * Advance a task to its next occurrence
181
194
  */
182
- static _advanceAction(action) {
195
+ static _advanceTask(task) {
183
196
  // Increment count
184
- action.count = (action.count || 0) + 1;
197
+ task.count = (task.count || 0) + 1;
185
198
 
186
199
  // Calculate next run time
187
- if (action.repeat) {
188
- const currentTime = action.date ? new Date(action.date) : new Date();
189
- const dstPolicy = action.repeat.dstPolicy || 'once';
200
+ if (task.repeat) {
201
+ const currentTime = task.date ? new Date(task.date) : new Date();
202
+ const dstPolicy = task.repeat.dstPolicy || 'once';
190
203
 
191
204
  const nextTime = RecurrenceEngine.getNextOccurrence(
192
205
  currentTime,
193
- action.repeat,
206
+ task.repeat,
194
207
  dstPolicy
195
208
  );
196
209
 
197
- action.date = nextTime;
210
+ task.date = nextTime;
198
211
  } else {
199
- // One-time action - clear date
200
- action.date = null;
212
+ // One-time task - clear date
213
+ task.date = null;
201
214
  }
202
215
  }
203
216
 
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
217
 
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
218
 
275
219
  /**
276
- * Simulate actions in a time range without mutating state
220
+ * Simulate tasks in a time range without mutating state
277
221
  */
278
222
  static simulate(state, startDate, endDate, maxIterations = 100000) {
279
223
  const simulatedEvents = [];
280
224
  const simulatedState = this._cloneState(state);
281
225
 
282
- // Start simulation from the earliest action time or startDate
226
+ // Start simulation from the earliest task time or startDate
283
227
  let currentTime = new Date(startDate);
284
228
 
285
229
  // Align to second boundary
@@ -296,11 +240,11 @@ class CoreEngine {
296
240
  iterationCount++;
297
241
 
298
242
  const { newState, events } = this.step(simulatedState, lastTick, currentTime, maxIterations);
299
- simulatedState.actions = newState.actions;
243
+ simulatedState.tasks = newState.tasks;
300
244
 
301
- // Collect action events (filter out errors for cleaner simulation output)
302
- const actionEvents = events.filter(e => e.type === 'action');
303
- simulatedEvents.push(...actionEvents);
245
+ // Collect task events (filter out errors for cleaner simulation output)
246
+ const taskEvents = events.filter(e => e.type === 'task');
247
+ simulatedEvents.push(...taskEvents);
304
248
 
305
249
  // Advance time by 1 second
306
250
  lastTick = currentTime;
@@ -7,7 +7,7 @@
7
7
 
8
8
  class RecurrenceEngine {
9
9
  /**
10
- * Calculate the next occurrence time for an action based on its recurrence rule.
10
+ * Calculate the next occurrence time for a task based on its recurrence rule.
11
11
  *
12
12
  * CRITICAL INVARIANT: nextTime.getTime() > currentTime.getTime()
13
13
  *
@@ -69,6 +69,67 @@ class RecurrenceEngine {
69
69
  return next;
70
70
  }
71
71
 
72
+ /**
73
+ * Fast-forward a task to a target time using mathematical projection.
74
+ * This is only applicable for simple, fixed-duration recurrence types.
75
+ *
76
+ * @param {Date} currentTime - The current scheduled time.
77
+ * @param {Object} repeat - The repeat configuration.
78
+ * @param {Date} targetTime - The time to project towards.
79
+ * @returns {Object|null} - { nextRun, skippedOccurrences } or null if not applicable.
80
+ */
81
+ static fastForward(currentTime, repeat, targetTime) {
82
+ if (!repeat || !repeat.type) {
83
+ return null;
84
+ }
85
+
86
+ const { type, interval = 1 } = repeat;
87
+ const timeToSkip = targetTime.getTime() - currentTime.getTime();
88
+
89
+ if (timeToSkip <= 0) {
90
+ return { nextRun: currentTime, skippedOccurrences: 0 };
91
+ }
92
+
93
+ let stepMilliseconds = 0;
94
+ switch (type) {
95
+ case 'second':
96
+ stepMilliseconds = interval * 1000;
97
+ break;
98
+ case 'minute':
99
+ stepMilliseconds = interval * 60 * 1000;
100
+ break;
101
+ case 'hour':
102
+ stepMilliseconds = interval * 60 * 60 * 1000;
103
+ break;
104
+ case 'day':
105
+ stepMilliseconds = interval * 24 * 60 * 60 * 1000;
106
+ break;
107
+ case 'week':
108
+ stepMilliseconds = interval * 7 * 24 * 60 * 60 * 1000;
109
+ break;
110
+ default:
111
+ // Complex recurrence types (weekday, weekend, month, year) cannot be fast-forwarded.
112
+ return null;
113
+ }
114
+
115
+ if (stepMilliseconds === 0) {
116
+ return null;
117
+ }
118
+
119
+ const stepsToSkip = Math.floor(timeToSkip / stepMilliseconds);
120
+
121
+ if (stepsToSkip <= 0) {
122
+ return { nextRun: currentTime, skippedOccurrences: 0 };
123
+ }
124
+
125
+ const newTime = new Date(currentTime.getTime() + (stepsToSkip * stepMilliseconds));
126
+
127
+ return {
128
+ nextRun: newTime,
129
+ skippedOccurrences: stepsToSkip,
130
+ };
131
+ }
132
+
72
133
  /**
73
134
  * Add seconds to a date
74
135
  */
@@ -206,16 +267,16 @@ class RecurrenceEngine {
206
267
  }
207
268
 
208
269
  /**
209
- * Check if an action should stop based on limit or endDate
270
+ * Check if a task should stop based on limit or endDate
210
271
  */
211
- static shouldStop(action) {
212
- const { repeat } = action;
272
+ static shouldStop(task) {
273
+ const { repeat } = task;
213
274
 
214
275
  if (!repeat) return true;
215
276
 
216
277
  // Check count limit
217
278
  if (repeat.limit !== null && repeat.limit !== undefined) {
218
- const count = action.count || 0;
279
+ const count = task.count || 0;
219
280
  if (count >= repeat.limit) {
220
281
  return true;
221
282
  }
@@ -224,7 +285,7 @@ class RecurrenceEngine {
224
285
  // Check end date
225
286
  if (repeat.endDate) {
226
287
  const endDate = new Date(repeat.endDate);
227
- const nextRun = new Date(action.date);
288
+ const nextRun = new Date(task.date);
228
289
  if (nextRun > endDate) {
229
290
  return true;
230
291
  }
@@ -11,11 +11,12 @@ const CoreEngine = require('../core/CoreEngine');
11
11
  class SchedulerHost extends EventEmitter {
12
12
  constructor() {
13
13
  super();
14
- this.state = { actions: [] };
14
+ this.state = { tasks: [] };
15
15
  this.running = false;
16
16
  this.lastTick = null;
17
17
  this.timer = null;
18
18
  this.functions = new Map();
19
+ this.bootMode = true;
19
20
  }
20
21
 
21
22
  /**
@@ -27,8 +28,48 @@ class SchedulerHost extends EventEmitter {
27
28
  }
28
29
 
29
30
  this.running = true;
30
- this.lastTick = new Date();
31
- this.lastTick.setMilliseconds(0);
31
+ const now = new Date();
32
+ now.setMilliseconds(0);
33
+
34
+ // Boot sweep - advance state to current time without executing tasks
35
+ if (this.bootMode) {
36
+ try {
37
+ // Boot sweep: advance state from lastTick to now without executing tasks
38
+ // If lastTick is null (first start), use now (no catch-up needed)
39
+ const { newState, events } = CoreEngine.step(this.state, this.lastTick || now, now);
40
+
41
+ // Update state (tasks are now advanced to current time)
42
+ this.state = newState;
43
+
44
+ // Process events to ensure state consistency, but don't execute callbacks
45
+ // The bootMode flag will prevent _executeTaskEvent from calling functions
46
+ for (const event of events) {
47
+ if (event.type === 'task') {
48
+ this._executeTaskEvent(event);
49
+ } else if (event.type === 'error') {
50
+ this.emit('error', event);
51
+ }
52
+ }
53
+
54
+ // Exit boot mode
55
+ this.bootMode = false;
56
+
57
+ // Signal to Automator that state should be saved
58
+ this.emit('boot-complete');
59
+
60
+ } catch (error) {
61
+ this.emit('error', {
62
+ type: 'error',
63
+ message: `Boot sweep error: ${error.message}`,
64
+ error
65
+ });
66
+ // Exit boot mode even on error to allow normal operation
67
+ this.bootMode = false;
68
+ }
69
+ }
70
+
71
+ // Set lastTick after boot sweep completes
72
+ this.lastTick = now;
32
73
 
33
74
  this._scheduleTick();
34
75
  this.emit('ready');
@@ -102,8 +143,8 @@ class SchedulerHost extends EventEmitter {
102
143
 
103
144
  // Process events
104
145
  for (const event of events) {
105
- if (event.type === 'action') {
106
- this._executeActionEvent(event);
146
+ if (event.type === 'task') {
147
+ this._executeTaskEvent(event);
107
148
  } else if (event.type === 'error') {
108
149
  this.emit('error', event);
109
150
  }
@@ -121,11 +162,16 @@ class SchedulerHost extends EventEmitter {
121
162
  }
122
163
 
123
164
  /**
124
- * Execute an action event by calling its registered function
165
+ * Execute a task event by calling its registered function
125
166
  */
126
- _executeActionEvent(event) {
127
- // Emit the action event
128
- this.emit('action', event);
167
+ _executeTaskEvent(event) {
168
+ // Skip execution during boot mode
169
+ if (this.bootMode) {
170
+ return;
171
+ }
172
+
173
+ // Emit the task event
174
+ this.emit('task', event);
129
175
 
130
176
  // Execute the command function if registered
131
177
  const fn = this.functions.get(event.cmd);
@@ -136,7 +182,7 @@ class SchedulerHost extends EventEmitter {
136
182
  this.emit('error', {
137
183
  type: 'error',
138
184
  message: `Error executing command ${event.cmd}: ${error.message}`,
139
- actionId: event.actionId,
185
+ taskId: event.taskId,
140
186
  error
141
187
  });
142
188
  }
@@ -144,16 +190,16 @@ class SchedulerHost extends EventEmitter {
144
190
  this.emit('debug', {
145
191
  type: 'debug',
146
192
  message: `No function registered for command: ${event.cmd}`,
147
- actionId: event.actionId
193
+ taskId: event.taskId
148
194
  });
149
195
  }
150
196
  }
151
197
 
152
198
  /**
153
- * Add an action to the state
199
+ * Add a task to the state
154
200
  */
155
- addAction(action) {
156
- this.state.actions.push(action);
201
+ addTask(task) {
202
+ this.state.tasks.push(task);
157
203
  }
158
204
 
159
205
  /**
@@ -1,59 +0,0 @@
1
- /**
2
- * FileStorage.js
3
- *
4
- * Pluggable file-based storage adapter
5
- */
6
-
7
- const fs = require('fs');
8
- const path = require('path');
9
-
10
- class FileStorage {
11
- constructor(filePath) {
12
- this.filePath = path.resolve(filePath);
13
- }
14
-
15
- /**
16
- * Load state from file
17
- */
18
- load() {
19
- try {
20
- if (fs.existsSync(this.filePath)) {
21
- const data = fs.readFileSync(this.filePath, 'utf8');
22
- const state = JSON.parse(data);
23
-
24
- // Convert date strings back to Date objects
25
- if (state.actions) {
26
- state.actions = state.actions.map(action => {
27
- if (action.date) {
28
- action.date = new Date(action.date);
29
- }
30
- if (action.repeat && action.repeat.endDate) {
31
- action.repeat.endDate = new Date(action.repeat.endDate);
32
- }
33
- return action;
34
- });
35
- }
36
-
37
- return state;
38
- }
39
- } catch (error) {
40
- throw new Error(`Failed to load from ${this.filePath}: ${error.message}`);
41
- }
42
-
43
- return { actions: [] };
44
- }
45
-
46
- /**
47
- * Save state to file
48
- */
49
- save(state) {
50
- try {
51
- const data = JSON.stringify(state, null, 2);
52
- fs.writeFileSync(this.filePath, data, 'utf8');
53
- } catch (error) {
54
- throw new Error(`Failed to save to ${this.filePath}: ${error.message}`);
55
- }
56
- }
57
- }
58
-
59
- module.exports = FileStorage;