@reventlessdev/reventless-core 3.0.0-alpha.293 → 3.0.0-alpha.295
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 +27 -0
- package/package.json +8 -8
- package/src/components/AutomationSlice/AutomationSlice_Builder.res +12 -4
- package/src/components/AutomationSlice/AutomationSlice_Builder.res.mjs +7 -2
- package/src/components/AutomationSlice/AutomationSlice_Callback.res +14 -2
- package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +21 -2
- package/src/plugin/connect/PluginExtensionPoint_Plugin.res +1 -1
- package/src/plugin/connect/PluginExtensionPoint_Plugin.res.mjs +1 -1
- package/src/util/ScheduleOps.res +1 -55
- package/src/util/ScheduleOps.res.mjs +10 -57
- package/src/util/ScheduleTime.res +266 -0
- package/src/util/ScheduleTime.res.mjs +451 -0
- package/tests/util/ScheduleTimeTest.res +256 -0
- package/tests/util/ScheduleTimeTest.res.mjs +532 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as DateTime from "@reventlessdev/rescript-effect/src/DateTime.res.mjs";
|
|
4
|
+
import * as Cron from "effect/Cron";
|
|
5
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
|
+
import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
|
|
7
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
|
+
import * as DateTime$1 from "effect/DateTime";
|
|
9
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
10
|
+
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
11
|
+
|
|
12
|
+
let InvalidTime = /* @__PURE__ */Primitive_exceptions.create("ScheduleTime-ReventlessCore.InvalidTime");
|
|
13
|
+
|
|
14
|
+
let utc = "UTC";
|
|
15
|
+
|
|
16
|
+
function zoneName(zone) {
|
|
17
|
+
return Stdlib_Option.getOr(zone, utc);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function zoneOrThrow(name) {
|
|
21
|
+
let zone = DateTime.zoneMakeNamed(name);
|
|
22
|
+
if (zone !== undefined) {
|
|
23
|
+
return Primitive_option.valFromOption(zone);
|
|
24
|
+
}
|
|
25
|
+
throw {
|
|
26
|
+
RE_EXN_ID: InvalidTime,
|
|
27
|
+
_1: `unknown time zone "` + name + `"`,
|
|
28
|
+
Error: new Error()
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function intervalMs(interval) {
|
|
33
|
+
switch (interval.TAG) {
|
|
34
|
+
case "Minutes" :
|
|
35
|
+
return interval._0 * 60000;
|
|
36
|
+
case "Hours" :
|
|
37
|
+
return interval._0 * 60 * 60000;
|
|
38
|
+
case "Days" :
|
|
39
|
+
return interval._0 * 24 * 60 * 60000;
|
|
40
|
+
case "Weeks" :
|
|
41
|
+
return interval._0 * 7 * 24 * 60 * 60000;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function weekdayIndex(day) {
|
|
46
|
+
switch (day) {
|
|
47
|
+
case "Mon" :
|
|
48
|
+
return 1;
|
|
49
|
+
case "Tue" :
|
|
50
|
+
return 2;
|
|
51
|
+
case "Wed" :
|
|
52
|
+
return 3;
|
|
53
|
+
case "Thu" :
|
|
54
|
+
return 4;
|
|
55
|
+
case "Fri" :
|
|
56
|
+
return 5;
|
|
57
|
+
case "Sat" :
|
|
58
|
+
return 6;
|
|
59
|
+
case "Sun" :
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function weekdayName(day) {
|
|
65
|
+
switch (day) {
|
|
66
|
+
case "Mon" :
|
|
67
|
+
return "MON";
|
|
68
|
+
case "Tue" :
|
|
69
|
+
return "TUE";
|
|
70
|
+
case "Wed" :
|
|
71
|
+
return "WED";
|
|
72
|
+
case "Thu" :
|
|
73
|
+
return "THU";
|
|
74
|
+
case "Fri" :
|
|
75
|
+
return "FRI";
|
|
76
|
+
case "Sat" :
|
|
77
|
+
return "SAT";
|
|
78
|
+
case "Sun" :
|
|
79
|
+
return "SUN";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function errors(time) {
|
|
84
|
+
let errs = [];
|
|
85
|
+
let check = (ok, message) => {
|
|
86
|
+
if (ok) {
|
|
87
|
+
return;
|
|
88
|
+
} else {
|
|
89
|
+
errs.push(message);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
let positive = (n, what) => check(n >= 1, what + ` must be at least 1, got ` + n.toString());
|
|
94
|
+
let clock = (hour, minute) => {
|
|
95
|
+
check(hour >= 0 && hour <= 23, `hour must be 0 to 23, got ` + hour.toString());
|
|
96
|
+
check(minute >= 0 && minute <= 59, `minute must be 0 to 59, got ` + minute.toString());
|
|
97
|
+
};
|
|
98
|
+
let dayOfMonth = day => check(day >= 1 && day <= 31, `day must be 1 to 31, got ` + day.toString());
|
|
99
|
+
let monthOfYear = month => check(month >= 1 && month <= 12, `month must be 1 to 12, got ` + month.toString());
|
|
100
|
+
let zoneKnown = zone => Stdlib_Option.forEach(zone, name => check(Stdlib_Option.isSome(DateTime.zoneMakeNamed(name)), `unknown time zone "` + name + `"`));
|
|
101
|
+
switch (time.TAG) {
|
|
102
|
+
case "Every" :
|
|
103
|
+
positive(time._0._0, "an interval");
|
|
104
|
+
break;
|
|
105
|
+
case "Calendar" :
|
|
106
|
+
let calendar = time.calendar;
|
|
107
|
+
zoneKnown(time.zone);
|
|
108
|
+
switch (calendar.TAG) {
|
|
109
|
+
case "Daily" :
|
|
110
|
+
clock(calendar.hour, calendar.minute);
|
|
111
|
+
break;
|
|
112
|
+
case "Weekly" :
|
|
113
|
+
check(calendar.days.length !== 0, "a weekly time needs at least one day");
|
|
114
|
+
clock(calendar.hour, calendar.minute);
|
|
115
|
+
break;
|
|
116
|
+
case "Monthly" :
|
|
117
|
+
dayOfMonth(calendar.day);
|
|
118
|
+
clock(calendar.hour, calendar.minute);
|
|
119
|
+
Stdlib_Option.forEach(calendar.every, every => check(every >= 1 && every <= 12, `every must be 1 to 12, got ` + every.toString()));
|
|
120
|
+
break;
|
|
121
|
+
case "Yearly" :
|
|
122
|
+
monthOfYear(calendar.month);
|
|
123
|
+
dayOfMonth(calendar.day);
|
|
124
|
+
clock(calendar.hour, calendar.minute);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
case "Once" :
|
|
129
|
+
let moment = time.moment;
|
|
130
|
+
zoneKnown(time.zone);
|
|
131
|
+
if (moment.TAG === "At") {
|
|
132
|
+
let match = moment._0;
|
|
133
|
+
monthOfYear(match.month);
|
|
134
|
+
dayOfMonth(match.day);
|
|
135
|
+
clock(match.hour, match.minute);
|
|
136
|
+
} else {
|
|
137
|
+
positive(moment._0._0, "a period");
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
return errs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validate(time) {
|
|
145
|
+
let errs = errors(time);
|
|
146
|
+
if (errs.length === 0) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
throw {
|
|
150
|
+
RE_EXN_ID: InvalidTime,
|
|
151
|
+
_1: errs.join("; "),
|
|
152
|
+
Error: new Error()
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function localTimeOf(millis, zone) {
|
|
157
|
+
let parts = DateTime$1.toParts(DateTime.makeZonedFromMillis(millis, zone));
|
|
158
|
+
return {
|
|
159
|
+
year: parts.year,
|
|
160
|
+
month: parts.month,
|
|
161
|
+
day: parts.day,
|
|
162
|
+
hour: parts.hours,
|
|
163
|
+
minute: parts.minutes
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function afterPeriod(period, from, zone) {
|
|
168
|
+
let start = DateTime.makeZonedFromMillis(from, zone);
|
|
169
|
+
switch (period.TAG) {
|
|
170
|
+
case "Minutes" :
|
|
171
|
+
return from + period._0 * 60000;
|
|
172
|
+
case "Hours" :
|
|
173
|
+
return from + period._0 * 60 * 60000;
|
|
174
|
+
case "Days" :
|
|
175
|
+
return DateTime$1.toEpochMillis(DateTime$1.add(start, {
|
|
176
|
+
days: period._0
|
|
177
|
+
}));
|
|
178
|
+
case "Weeks" :
|
|
179
|
+
return DateTime$1.toEpochMillis(DateTime$1.add(start, {
|
|
180
|
+
weeks: period._0
|
|
181
|
+
}));
|
|
182
|
+
case "Months" :
|
|
183
|
+
return DateTime$1.toEpochMillis(DateTime$1.add(start, {
|
|
184
|
+
months: period._0
|
|
185
|
+
}));
|
|
186
|
+
case "Years" :
|
|
187
|
+
return DateTime$1.toEpochMillis(DateTime$1.add(start, {
|
|
188
|
+
years: period._0
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveOnce(time, from) {
|
|
194
|
+
switch (time.TAG) {
|
|
195
|
+
case "Every" :
|
|
196
|
+
case "Calendar" :
|
|
197
|
+
return time;
|
|
198
|
+
case "Once" :
|
|
199
|
+
let period = time.moment;
|
|
200
|
+
if (period.TAG === "At") {
|
|
201
|
+
return time;
|
|
202
|
+
}
|
|
203
|
+
let zone = time.zone;
|
|
204
|
+
let tz = zoneOrThrow(Stdlib_Option.getOr(zone, utc));
|
|
205
|
+
return {
|
|
206
|
+
TAG: "Once",
|
|
207
|
+
moment: {
|
|
208
|
+
TAG: "At",
|
|
209
|
+
_0: localTimeOf(afterPeriod(period._0, from, tz), tz)
|
|
210
|
+
},
|
|
211
|
+
zone: zone
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function instantOf(param, zone) {
|
|
217
|
+
let day = param.day;
|
|
218
|
+
let month = param.month;
|
|
219
|
+
let year = param.year;
|
|
220
|
+
let dateTime = DateTime.makeZonedFromParts({
|
|
221
|
+
year: year,
|
|
222
|
+
month: month,
|
|
223
|
+
day: day,
|
|
224
|
+
hours: param.hour,
|
|
225
|
+
minutes: param.minute
|
|
226
|
+
}, zone);
|
|
227
|
+
if (dateTime !== undefined) {
|
|
228
|
+
return DateTime$1.toEpochMillis(Primitive_option.valFromOption(dateTime));
|
|
229
|
+
}
|
|
230
|
+
throw {
|
|
231
|
+
RE_EXN_ID: InvalidTime,
|
|
232
|
+
_1: `no such time: ` + year.toString() + `-` + month.toString() + `-` + day.toString(),
|
|
233
|
+
Error: new Error()
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function wallClockOf(calendar) {
|
|
238
|
+
switch (calendar.TAG) {
|
|
239
|
+
case "Daily" :
|
|
240
|
+
return [
|
|
241
|
+
calendar.hour,
|
|
242
|
+
calendar.minute
|
|
243
|
+
];
|
|
244
|
+
case "Weekly" :
|
|
245
|
+
case "Monthly" :
|
|
246
|
+
return [
|
|
247
|
+
calendar.hour,
|
|
248
|
+
calendar.minute
|
|
249
|
+
];
|
|
250
|
+
case "Yearly" :
|
|
251
|
+
return [
|
|
252
|
+
calendar.hour,
|
|
253
|
+
calendar.minute
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function monthsEvery(every) {
|
|
259
|
+
if (every <= 1) {
|
|
260
|
+
return [];
|
|
261
|
+
} else {
|
|
262
|
+
return Stdlib_Array.fromInitializer(Primitive_int.div(11, every) + 1 | 0, i => 1 + (i * every | 0) | 0);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function cronOf(calendar, zone) {
|
|
267
|
+
let match = wallClockOf(calendar);
|
|
268
|
+
let minute = match[1];
|
|
269
|
+
let hour = match[0];
|
|
270
|
+
let at = (daysOpt, monthsOpt, weekdaysOpt) => {
|
|
271
|
+
let days = daysOpt !== undefined ? daysOpt : [];
|
|
272
|
+
let months = monthsOpt !== undefined ? monthsOpt : [];
|
|
273
|
+
let weekdays = weekdaysOpt !== undefined ? weekdaysOpt : [];
|
|
274
|
+
return Cron.make({
|
|
275
|
+
minutes: [minute],
|
|
276
|
+
hours: [hour],
|
|
277
|
+
days: days,
|
|
278
|
+
months: months,
|
|
279
|
+
weekdays: weekdays,
|
|
280
|
+
tz: zone
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
switch (calendar.TAG) {
|
|
284
|
+
case "Daily" :
|
|
285
|
+
return at(undefined, undefined, undefined);
|
|
286
|
+
case "Weekly" :
|
|
287
|
+
return at(undefined, undefined, calendar.days.map(weekdayIndex));
|
|
288
|
+
case "Monthly" :
|
|
289
|
+
return at([calendar.day], monthsEvery(Stdlib_Option.getOr(calendar.every, 1)), undefined);
|
|
290
|
+
case "Yearly" :
|
|
291
|
+
return at([calendar.day], [calendar.month], undefined);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function nextOnWallClock(cron, after, hour, minute, zone) {
|
|
296
|
+
let _from = after;
|
|
297
|
+
let _tries = 0;
|
|
298
|
+
while (true) {
|
|
299
|
+
let tries = _tries;
|
|
300
|
+
let from = _from;
|
|
301
|
+
let next = Cron.next(cron, new Date(from)).getTime();
|
|
302
|
+
let parts = DateTime$1.toParts(DateTime.makeZonedFromMillis(next, zone));
|
|
303
|
+
if (parts.hours === hour && parts.minutes === minute || tries >= 8) {
|
|
304
|
+
return next;
|
|
305
|
+
}
|
|
306
|
+
_tries = tries + 1 | 0;
|
|
307
|
+
_from = next;
|
|
308
|
+
continue;
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function nextFiring(time, after) {
|
|
313
|
+
let interval = resolveOnce(time, after);
|
|
314
|
+
switch (interval.TAG) {
|
|
315
|
+
case "Every" :
|
|
316
|
+
return after + intervalMs(interval._0);
|
|
317
|
+
case "Calendar" :
|
|
318
|
+
let calendar = interval.calendar;
|
|
319
|
+
let tz = zoneOrThrow(Stdlib_Option.getOr(interval.zone, utc));
|
|
320
|
+
let match = wallClockOf(calendar);
|
|
321
|
+
return nextOnWallClock(cronOf(calendar, tz), after, match[0], match[1], tz);
|
|
322
|
+
case "Once" :
|
|
323
|
+
let localTime = interval.moment;
|
|
324
|
+
if (localTime.TAG !== "At") {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
let at = instantOf(localTime._0, zoneOrThrow(Stdlib_Option.getOr(interval.zone, utc)));
|
|
328
|
+
if (at > after) {
|
|
329
|
+
return at;
|
|
330
|
+
} else {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function pad2(n) {
|
|
337
|
+
if (n < 10) {
|
|
338
|
+
return `0` + n.toString();
|
|
339
|
+
} else {
|
|
340
|
+
return n.toString();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function rateOf(n, unit) {
|
|
345
|
+
return `rate(` + n.toString() + ` ` + unit + (
|
|
346
|
+
n === 1 ? "" : "s"
|
|
347
|
+
) + `)`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function cronExpressionOf(calendar) {
|
|
351
|
+
let match = wallClockOf(calendar);
|
|
352
|
+
let fields;
|
|
353
|
+
switch (calendar.TAG) {
|
|
354
|
+
case "Daily" :
|
|
355
|
+
fields = "* * ? *";
|
|
356
|
+
break;
|
|
357
|
+
case "Weekly" :
|
|
358
|
+
fields = `? * ` + calendar.days.map(weekdayName).join(",") + ` *`;
|
|
359
|
+
break;
|
|
360
|
+
case "Monthly" :
|
|
361
|
+
let every = calendar.every;
|
|
362
|
+
let month = every !== undefined && every > 1 ? `1/` + every.toString() : "*";
|
|
363
|
+
fields = calendar.day.toString() + ` ` + month + ` ? *`;
|
|
364
|
+
break;
|
|
365
|
+
case "Yearly" :
|
|
366
|
+
fields = calendar.day.toString() + ` ` + calendar.month.toString() + ` ? *`;
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
return `cron(` + match[1].toString() + ` ` + match[0].toString() + ` ` + fields + `)`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function toAws(time, setAt) {
|
|
373
|
+
let interval = resolveOnce(time, setAt);
|
|
374
|
+
switch (interval.TAG) {
|
|
375
|
+
case "Every" :
|
|
376
|
+
let interval$1 = interval._0;
|
|
377
|
+
let tmp;
|
|
378
|
+
switch (interval$1.TAG) {
|
|
379
|
+
case "Minutes" :
|
|
380
|
+
tmp = rateOf(interval$1._0, "minute");
|
|
381
|
+
break;
|
|
382
|
+
case "Hours" :
|
|
383
|
+
tmp = rateOf(interval$1._0, "hour");
|
|
384
|
+
break;
|
|
385
|
+
case "Days" :
|
|
386
|
+
tmp = rateOf(interval$1._0, "day");
|
|
387
|
+
break;
|
|
388
|
+
case "Weeks" :
|
|
389
|
+
tmp = rateOf(interval$1._0 * 7 | 0, "day");
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
expression: tmp,
|
|
394
|
+
timezone: undefined,
|
|
395
|
+
startDate: setAt + intervalMs(interval$1),
|
|
396
|
+
deleteAfterCompletion: false
|
|
397
|
+
};
|
|
398
|
+
case "Calendar" :
|
|
399
|
+
return {
|
|
400
|
+
expression: cronExpressionOf(interval.calendar),
|
|
401
|
+
timezone: Stdlib_Option.getOr(interval.zone, utc),
|
|
402
|
+
startDate: undefined,
|
|
403
|
+
deleteAfterCompletion: false
|
|
404
|
+
};
|
|
405
|
+
case "Once" :
|
|
406
|
+
let match = interval.moment;
|
|
407
|
+
if (match.TAG === "At") {
|
|
408
|
+
let match$1 = match._0;
|
|
409
|
+
return {
|
|
410
|
+
expression: `at(` + match$1.year.toString() + `-` + pad2(match$1.month) + `-` + pad2(match$1.day) + `T` + pad2(match$1.hour) + `:` + pad2(match$1.minute) + `:00)`,
|
|
411
|
+
timezone: Stdlib_Option.getOr(interval.zone, utc),
|
|
412
|
+
startDate: undefined,
|
|
413
|
+
deleteAfterCompletion: true
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
throw {
|
|
417
|
+
RE_EXN_ID: InvalidTime,
|
|
418
|
+
_1: "unresolved once-only time",
|
|
419
|
+
Error: new Error()
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let minuteMs = 60000;
|
|
425
|
+
|
|
426
|
+
export {
|
|
427
|
+
InvalidTime,
|
|
428
|
+
utc,
|
|
429
|
+
zoneName,
|
|
430
|
+
zoneOrThrow,
|
|
431
|
+
minuteMs,
|
|
432
|
+
intervalMs,
|
|
433
|
+
weekdayIndex,
|
|
434
|
+
weekdayName,
|
|
435
|
+
errors,
|
|
436
|
+
validate,
|
|
437
|
+
localTimeOf,
|
|
438
|
+
afterPeriod,
|
|
439
|
+
resolveOnce,
|
|
440
|
+
instantOf,
|
|
441
|
+
wallClockOf,
|
|
442
|
+
monthsEvery,
|
|
443
|
+
cronOf,
|
|
444
|
+
nextOnWallClock,
|
|
445
|
+
nextFiring,
|
|
446
|
+
pad2,
|
|
447
|
+
rateOf,
|
|
448
|
+
cronExpressionOf,
|
|
449
|
+
toAws,
|
|
450
|
+
}
|
|
451
|
+
/* DateTime Not a pure module */
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// When each shape of a Schedule.time fires, and that the AWS expression of a calendar
|
|
2
|
+
// time names the same firings the local timer waits for.
|
|
3
|
+
|
|
4
|
+
open JestGlobals
|
|
5
|
+
open Reventless.Schedule
|
|
6
|
+
|
|
7
|
+
let at = iso => Date.fromString(iso)->Date.getTime
|
|
8
|
+
let iso = millis => millis->Date.fromTime->Date.toISOString
|
|
9
|
+
|
|
10
|
+
/** The first `n` firings of `time` after `from`, in UTC. */
|
|
11
|
+
let firings = (time, ~from, ~n) => {
|
|
12
|
+
let out = []
|
|
13
|
+
let rec loop = after =>
|
|
14
|
+
switch out->Array.length < n ? ScheduleTime.nextFiring(time, ~after) : None {
|
|
15
|
+
| Some(next) =>
|
|
16
|
+
out->Array.push(next->iso)
|
|
17
|
+
loop(next)
|
|
18
|
+
| None => ()
|
|
19
|
+
}
|
|
20
|
+
loop(from->at)
|
|
21
|
+
out
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("ScheduleTime.nextFiring", () => {
|
|
25
|
+
testSync("Every counts from the previous firing, with no zone", () => {
|
|
26
|
+
expect(firings(Every(Hours(2)), ~from="2026-03-29T00:30:00Z", ~n=2))->toEqual([
|
|
27
|
+
"2026-03-29T02:30:00.000Z",
|
|
28
|
+
"2026-03-29T04:30:00.000Z",
|
|
29
|
+
])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
testSync("Daily is a wall-clock time in its zone, across both daylight-saving changes", () => {
|
|
33
|
+
let daily = Calendar({calendar: Daily({hour: 6, minute: 0}), zone: "Europe/Berlin"})
|
|
34
|
+
expect(firings(daily, ~from="2026-03-28T12:00:00Z", ~n=2))->toEqual([
|
|
35
|
+
"2026-03-29T04:00:00.000Z",
|
|
36
|
+
"2026-03-30T04:00:00.000Z",
|
|
37
|
+
])
|
|
38
|
+
expect(firings(daily, ~from="2026-10-24T12:00:00Z", ~n=2))->toEqual([
|
|
39
|
+
"2026-10-25T05:00:00.000Z",
|
|
40
|
+
"2026-10-26T05:00:00.000Z",
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
testSync("a time in the skipped hour does not fire that day; in the repeated hour, once", () => {
|
|
45
|
+
let night = Calendar({calendar: Daily({hour: 2, minute: 30}), zone: "Europe/Berlin"})
|
|
46
|
+
expect(firings(night, ~from="2026-03-27T12:00:00Z", ~n=2))->toEqual([
|
|
47
|
+
"2026-03-28T01:30:00.000Z",
|
|
48
|
+
"2026-03-30T00:30:00.000Z",
|
|
49
|
+
])
|
|
50
|
+
expect(firings(night, ~from="2026-10-24T12:00:00Z", ~n=2))->toEqual([
|
|
51
|
+
"2026-10-25T00:30:00.000Z",
|
|
52
|
+
"2026-10-26T01:30:00.000Z",
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
testSync("Weekly fires on its days only", () => {
|
|
57
|
+
let weekdays = Calendar({calendar: Weekly({days: [Mon, Fri], hour: 7, minute: 30})})
|
|
58
|
+
expect(firings(weekdays, ~from="2026-10-16T08:00:00Z", ~n=3))->toEqual([
|
|
59
|
+
"2026-10-19T07:30:00.000Z",
|
|
60
|
+
"2026-10-23T07:30:00.000Z",
|
|
61
|
+
"2026-10-26T07:30:00.000Z",
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
testSync("Monthly on the 29th to 31st skips the months without that day", () => {
|
|
66
|
+
let on = day => Calendar({calendar: Monthly({day, hour: 6, minute: 0})})
|
|
67
|
+
expect(firings(on(29), ~from="2027-01-30T00:00:00Z", ~n=1))->toEqual([
|
|
68
|
+
"2027-03-29T06:00:00.000Z",
|
|
69
|
+
])
|
|
70
|
+
expect(firings(on(30), ~from="2027-01-31T00:00:00Z", ~n=1))->toEqual([
|
|
71
|
+
"2027-03-30T06:00:00.000Z",
|
|
72
|
+
])
|
|
73
|
+
expect(firings(on(31), ~from="2026-04-01T00:00:00Z", ~n=2))->toEqual([
|
|
74
|
+
"2026-05-31T06:00:00.000Z",
|
|
75
|
+
"2026-07-31T06:00:00.000Z",
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
testSync("Monthly with every fires every so many months, from January", () => {
|
|
80
|
+
let quarterly = Calendar({calendar: Monthly({day: 1, hour: 6, minute: 0, every: 3})})
|
|
81
|
+
expect(firings(quarterly, ~from="2026-02-15T00:00:00Z", ~n=4))->toEqual([
|
|
82
|
+
"2026-04-01T06:00:00.000Z",
|
|
83
|
+
"2026-07-01T06:00:00.000Z",
|
|
84
|
+
"2026-10-01T06:00:00.000Z",
|
|
85
|
+
"2027-01-01T06:00:00.000Z",
|
|
86
|
+
])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
testSync("Yearly on 29 February fires in leap years", () => {
|
|
90
|
+
let leap = Calendar({calendar: Yearly({month: 2, day: 29, hour: 6, minute: 0})})
|
|
91
|
+
expect(firings(leap, ~from="2026-01-01T00:00:00Z", ~n=2))->toEqual([
|
|
92
|
+
"2028-02-29T06:00:00.000Z",
|
|
93
|
+
"2032-02-29T06:00:00.000Z",
|
|
94
|
+
])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
testSync("Once at a local time fires once, read in its zone; once past, never", () => {
|
|
98
|
+
let newYear = Once({
|
|
99
|
+
moment: At({year: 2026, month: 12, day: 31, hour: 23, minute: 59}),
|
|
100
|
+
zone: "Europe/Berlin",
|
|
101
|
+
})
|
|
102
|
+
expect(firings(newYear, ~from="2026-10-16T00:00:00Z", ~n=3))->toEqual([
|
|
103
|
+
"2026-12-31T22:59:00.000Z",
|
|
104
|
+
])
|
|
105
|
+
expect(firings(newYear, ~from="2027-01-01T00:00:00Z", ~n=1))->toEqual([])
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe("ScheduleTime.resolveOnce", () => {
|
|
110
|
+
let resolved = (period, ~from, ~zone="UTC") =>
|
|
111
|
+
switch Once({moment: After(period), zone})->ScheduleTime.resolveOnce(~from=from->at) {
|
|
112
|
+
| Once({moment: At(localTime)}) => Some(localTime)
|
|
113
|
+
| _ => None
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
testSync("a month from 31 January is the last day of February", () => {
|
|
117
|
+
expect(resolved(Months(1), ~from="2026-01-31T09:00:00Z"))->toEqual(
|
|
118
|
+
Some({year: 2026, month: 2, day: 28, hour: 9, minute: 0}),
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
testSync("a day keeps the wall-clock time across a daylight-saving change", () => {
|
|
123
|
+
expect(resolved(Days(1), ~from="2026-03-28T05:00:00Z", ~zone="Europe/Berlin"))->toEqual(
|
|
124
|
+
Some({year: 2026, month: 3, day: 29, hour: 6, minute: 0}),
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
testSync("24 hours are elapsed time across the same change", () => {
|
|
129
|
+
expect(resolved(Hours(24), ~from="2026-03-28T05:00:00Z", ~zone="Europe/Berlin"))->toEqual(
|
|
130
|
+
Some({year: 2026, month: 3, day: 29, hour: 7, minute: 0}),
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
testSync("minutes are counted from the moment it is set, to the minute", () => {
|
|
135
|
+
expect(resolved(Minutes(10), ~from="2026-10-15T14:02:37Z"))->toEqual(
|
|
136
|
+
Some({year: 2026, month: 10, day: 15, hour: 14, minute: 12}),
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe("ScheduleTime.toAws", () => {
|
|
142
|
+
let setAt = "2026-10-15T14:02:00Z"->at
|
|
143
|
+
|
|
144
|
+
testSync("an interval is a rate, starting one interval later", () => {
|
|
145
|
+
let aws = Every(Weeks(2))->ScheduleTime.toAws(~setAt)
|
|
146
|
+
expect(aws.expression)->toBe("rate(14 days)")
|
|
147
|
+
expect(aws.startDate)->toEqual(Some(setAt +. 14. *. 24. *. 60. *. 60_000.))
|
|
148
|
+
expect(aws.timezone)->toEqual(None)
|
|
149
|
+
expect((Every(Minutes(1))->ScheduleTime.toAws(~setAt)).expression)->toBe("rate(1 minute)")
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
testSync("a calendar time is a cron with its zone, UTC when it has none", () => {
|
|
153
|
+
let daily = Calendar({calendar: Daily({hour: 6, minute: 0})})->ScheduleTime.toAws(~setAt)
|
|
154
|
+
expect(daily.expression)->toBe("cron(0 6 * * ? *)")
|
|
155
|
+
expect(daily.timezone)->toEqual(Some("UTC"))
|
|
156
|
+
let weekly = Calendar({
|
|
157
|
+
calendar: Weekly({days: [Mon, Tue, Wed, Thu, Fri], hour: 7, minute: 30}),
|
|
158
|
+
zone: "Europe/Berlin",
|
|
159
|
+
})->ScheduleTime.toAws(~setAt)
|
|
160
|
+
expect(weekly.expression)->toBe("cron(30 7 ? * MON,TUE,WED,THU,FRI *)")
|
|
161
|
+
expect(weekly.timezone)->toEqual(Some("Europe/Berlin"))
|
|
162
|
+
expect(
|
|
163
|
+
(
|
|
164
|
+
Calendar({calendar: Monthly({day: 1, hour: 6, minute: 0, every: 3})})->ScheduleTime.toAws(
|
|
165
|
+
~setAt,
|
|
166
|
+
)
|
|
167
|
+
).expression,
|
|
168
|
+
)->toBe("cron(0 6 1 1/3 ? *)")
|
|
169
|
+
expect(
|
|
170
|
+
(
|
|
171
|
+
Calendar({calendar: Yearly({month: 12, day: 24, hour: 18, minute: 0})})->ScheduleTime.toAws(
|
|
172
|
+
~setAt,
|
|
173
|
+
)
|
|
174
|
+
).expression,
|
|
175
|
+
)->toBe("cron(0 18 24 12 ? *)")
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
testSync("a once-only time is an at in its zone, deleted after it fires", () => {
|
|
179
|
+
let aws = Once({moment: After(Days(3)), zone: "Europe/Berlin"})->ScheduleTime.toAws(~setAt)
|
|
180
|
+
expect(aws.expression)->toBe("at(2026-10-18T16:02:00)")
|
|
181
|
+
expect(aws.timezone)->toEqual(Some("Europe/Berlin"))
|
|
182
|
+
expect(aws.deleteAfterCompletion)->toBe(true)
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe("the two platforms agree", () => {
|
|
187
|
+
// The AWS cron, read as the five-field expression Effect parses, and the local
|
|
188
|
+
// nextFiring give the same next ten firings. Daylight saving is AWS's documented rule.
|
|
189
|
+
let fromAws = (calendar, ~zone) => {
|
|
190
|
+
let fields =
|
|
191
|
+
calendar
|
|
192
|
+
->ScheduleTime.cronExpressionOf
|
|
193
|
+
->String.slice(~start=5, ~end=-1)
|
|
194
|
+
->String.split(" ")
|
|
195
|
+
->Array.map(f => f == "?" ? "*" : f)
|
|
196
|
+
let expression = fields->Array.slice(~start=0, ~end=5)->Array.join(" ")
|
|
197
|
+
let tz = ScheduleTime.zoneOrThrow(zone)
|
|
198
|
+
let (hour, minute) = calendar->ScheduleTime.wallClockOf
|
|
199
|
+
let cron = Cron.parseOrThrow(expression, tz)
|
|
200
|
+
let out = []
|
|
201
|
+
let rec loop = after =>
|
|
202
|
+
if out->Array.length < 10 {
|
|
203
|
+
let next = cron->ScheduleTime.nextOnWallClock(~after, ~hour, ~minute, ~zone=tz)
|
|
204
|
+
out->Array.push(next->iso)
|
|
205
|
+
loop(next)
|
|
206
|
+
}
|
|
207
|
+
loop("2026-01-01T00:00:00Z"->at)
|
|
208
|
+
out
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let cases: array<(string, calendar, string)> = [
|
|
212
|
+
("daily", Daily({hour: 6, minute: 0}), "UTC"),
|
|
213
|
+
("daily in the skipped hour", Daily({hour: 2, minute: 30}), "Europe/Berlin"),
|
|
214
|
+
(
|
|
215
|
+
"weekdays",
|
|
216
|
+
Weekly({days: [Mon, Tue, Wed, Thu, Fri], hour: 7, minute: 30}),
|
|
217
|
+
"America/New_York",
|
|
218
|
+
),
|
|
219
|
+
("weekends", Weekly({days: [Sat, Sun], hour: 23, minute: 59}), "Australia/Sydney"),
|
|
220
|
+
("monthly on the 31st", Monthly({day: 31, hour: 6, minute: 0}), "Europe/Berlin"),
|
|
221
|
+
("quarterly", Monthly({day: 15, hour: 9, minute: 15, every: 3}), "Asia/Kolkata"),
|
|
222
|
+
("every five months", Monthly({day: 1, hour: 0, minute: 0, every: 5}), "UTC"),
|
|
223
|
+
("yearly", Yearly({month: 3, day: 29, hour: 2, minute: 30}), "Europe/London"),
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
cases->Array.forEach(((label, calendar, zone)) =>
|
|
227
|
+
testSync(
|
|
228
|
+
label,
|
|
229
|
+
() => {
|
|
230
|
+
expect(fromAws(calendar, ~zone))->toEqual(
|
|
231
|
+
firings(Calendar({calendar, zone}), ~from="2026-01-01T00:00:00Z", ~n=10),
|
|
232
|
+
)
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe("ScheduleTime.validate", () => {
|
|
239
|
+
testSync("names every problem of a time", () => {
|
|
240
|
+
let message = switch ScheduleTime.validate(
|
|
241
|
+
Calendar({calendar: Weekly({days: [], hour: 24, minute: 0}), zone: "Mars/Base"}),
|
|
242
|
+
) {
|
|
243
|
+
| () => ""
|
|
244
|
+
| exception ScheduleTime.InvalidTime(message) => message
|
|
245
|
+
}
|
|
246
|
+
expect(
|
|
247
|
+
message,
|
|
248
|
+
)->toBe(`unknown time zone "Mars/Base"; a weekly time needs at least one day; hour must be 0 to 23, got 24`)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
testSync("accepts a time that can fire", () => {
|
|
252
|
+
expect(
|
|
253
|
+
ScheduleTime.errors(Once({moment: After(Years(1)), zone: "Pacific/Auckland"})),
|
|
254
|
+
)->toEqual([])
|
|
255
|
+
})
|
|
256
|
+
})
|