@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
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,33 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.295 (2026-10-15)
|
|
7
|
+
|
|
8
|
+
* feat!: an automation's process takes ~now, and collect the event's recorded time ([8c8f3f5](https://github.com/ReventlessDev/reventless-core/commit/8c8f3f570474b1658b605b726077debc7d6a1dc7))
|
|
9
|
+
* feat!: schedules take a Schedule.time, run on EventBridge Scheduler on AWS ([2d74557](https://github.com/ReventlessDev/reventless-core/commit/2d74557a6304917ded8f2b9f4107af9298c91ac0))
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* **local:** sweep pending automation items, as AWS does ([96acec0](https://github.com/ReventlessDev/reventless-core/commit/96acec0ba226ee560790bd14b6fd38e44328a47c))
|
|
13
|
+
|
|
14
|
+
### BREAKING CHANGES
|
|
15
|
+
|
|
16
|
+
* a process the PPX does not see — in an Automation module
|
|
17
|
+
written by hand, or a SliceSpec given to the deprecated Automation_GWT.Make
|
|
18
|
+
or Flow_GWT.AutomationStep — must take ~now.
|
|
19
|
+
* Schedule.rate is replaced by Schedule.time, and a
|
|
20
|
+
schedule's `rate` field is now `time`. A once-only time already past
|
|
21
|
+
never fires, locally as on AWS.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# 3.0.0-alpha.294 (2026-10-12)
|
|
26
|
+
|
|
27
|
+
**Note:** Version bump only for package @reventlessdev/reventless-core
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
6
33
|
# 3.0.0-alpha.293 (2026-10-10)
|
|
7
34
|
|
|
8
35
|
* feat!: a command's rule is looked up by its name, without a cast ([5f02c40](https://github.com/ReventlessDev/reventless-core/commit/5f02c4081d077bf4a6951d8c9b2b16553a8ca87b))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.295",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,22 +28,22 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
-
"@reventlessdev/rescript-
|
|
32
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.34",
|
|
33
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.22",
|
|
31
|
+
"@reventlessdev/rescript-effect": "1.0.0-alpha.0",
|
|
34
32
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
|
|
35
33
|
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.22",
|
|
34
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.12",
|
|
36
35
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.23",
|
|
37
36
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.23",
|
|
37
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.22",
|
|
38
38
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
|
|
39
|
-
"@reventlessdev/reventless-
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
39
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.192",
|
|
40
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.40",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.164"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.1",
|
|
45
45
|
"sury-ppx": "11.0.0",
|
|
46
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
46
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.100"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"rescript": "^12.3.1"
|
|
@@ -190,12 +190,20 @@ module Make = (
|
|
|
190
190
|
})
|
|
191
191
|
|
|
192
192
|
self->Component.setOperations(
|
|
193
|
-
(
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
(
|
|
194
|
+
eventCollector->Pulumi.Output.flatMap(ec => ec->Component.operations),
|
|
195
|
+
publishJsons,
|
|
196
|
+
queryDb->Component.operations,
|
|
197
|
+
)
|
|
198
|
+
->Pulumi.Output.all3
|
|
199
|
+
->Pulumi.Output.apply(((ecOps, publishJsonsFn, queryDbOps)) => {
|
|
196
200
|
let ops: AutomationSlice.operations = {
|
|
197
201
|
enqueueEvent: ecOps.enqueueEvent,
|
|
198
|
-
|
|
202
|
+
// As the AWS sweep does: the rows phase 2 moved are written back.
|
|
203
|
+
processPending: async () => {
|
|
204
|
+
await Callback.phase2(publishJsonsFn)
|
|
205
|
+
await syncToQueryDb(queryDbOps)
|
|
206
|
+
},
|
|
199
207
|
}
|
|
200
208
|
ops
|
|
201
209
|
}),
|
|
@@ -95,12 +95,17 @@ function Make(RuntimeEnvironment) {
|
|
|
95
95
|
});
|
|
96
96
|
Component$ReventlessCore.setOperations(extra, Pulumi.all([
|
|
97
97
|
Output$Pulumi.flatMap(eventCollector, Component$ReventlessCore.operations),
|
|
98
|
-
publishJsons
|
|
98
|
+
publishJsons,
|
|
99
|
+
Component$ReventlessCore.operations(queryDb)
|
|
99
100
|
]).apply(param => {
|
|
101
|
+
let queryDbOps = param[2];
|
|
100
102
|
let publishJsonsFn = param[1];
|
|
101
103
|
return {
|
|
102
104
|
enqueueEvent: param[0].enqueueEvent,
|
|
103
|
-
processPending: async () =>
|
|
105
|
+
processPending: async () => {
|
|
106
|
+
await Callback.phase2(publishJsonsFn);
|
|
107
|
+
return await syncToQueryDb(queryDbOps);
|
|
108
|
+
}
|
|
104
109
|
};
|
|
105
110
|
}));
|
|
106
111
|
let aggregatedResources = Object.values(eventTopics).flatMap(t => t.resources);
|
|
@@ -168,6 +168,13 @@ module Make = (
|
|
|
168
168
|
|
|
169
169
|
module Route = AutomationSlice_Route.Make(Spec, Automation)
|
|
170
170
|
|
|
171
|
+
// An event's recorded time, from its envelope's ISO `meta.time`.
|
|
172
|
+
let recordedAtOf = time =>
|
|
173
|
+
switch Date.fromString(time)->Date.getTime {
|
|
174
|
+
| millis if Float.isNaN(millis) => None
|
|
175
|
+
| millis => Some(millis)
|
|
176
|
+
}
|
|
177
|
+
|
|
171
178
|
let apply = ({collected, resolved}: AutomationSlice_Route.routed<Spec.todoItem>) => {
|
|
172
179
|
// Collect — append new items keyed by ID; first writer wins (idempotent).
|
|
173
180
|
collected->Array.forEach(((id, item)) => {
|
|
@@ -194,9 +201,13 @@ module Make = (
|
|
|
194
201
|
)
|
|
195
202
|
}
|
|
196
203
|
|
|
197
|
-
let phase1 = (events: array<JSON.t>,
|
|
204
|
+
let phase1 = (events: array<JSON.t>, sliceCtx: Reventless.AutomationSlice.context) => {
|
|
198
205
|
events->Array.forEach(json => {
|
|
199
206
|
let context = json->Message.decode(Reventless.Message.contextSchema)
|
|
207
|
+
let ctx = switch recordedAtOf(context.meta.time) {
|
|
208
|
+
| Some(recordedAt) => {...sliceCtx, recordedAt}
|
|
209
|
+
| None => sliceCtx
|
|
210
|
+
}
|
|
200
211
|
let sourceName = context.meta.service
|
|
201
212
|
// Events arrive as `{id, meta, event}` envelopes (see Message.composeEventJson').
|
|
202
213
|
// Per-mapping decoders work on the inner `event` payload (TAG + fields at
|
|
@@ -211,6 +222,7 @@ module Make = (
|
|
|
211
222
|
}
|
|
212
223
|
|
|
213
224
|
let phase2 = async (publishJsons: ReventlessInfra.CommandTopic.publishJsons) => {
|
|
225
|
+
let currentTime = await Clock.currentTimeMillis->Effect.runPromise
|
|
214
226
|
// A `Failed` row at or past the ceiling is one an older build left stranded,
|
|
215
227
|
// or one whose Spec lowered `maxRetries` under it. Either way it is abandoned
|
|
216
228
|
// in fact, so say so rather than letting it sit in a status that promises a
|
|
@@ -247,7 +259,7 @@ module Make = (
|
|
|
247
259
|
}
|
|
248
260
|
|
|
249
261
|
item->Option.forEach(item => {
|
|
250
|
-
switch Automation.process(id, item) {
|
|
262
|
+
switch Automation.process(id, item, ~now=currentTime) {
|
|
251
263
|
| Some((targetId, command)) =>
|
|
252
264
|
todoItems->Dict.set(id, {...row, status: Processing, processedAt: now()})
|
|
253
265
|
let commandJson = try command
|
|
@@ -4,6 +4,7 @@ import * as Sury from "sury";
|
|
|
4
4
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
5
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
6
|
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
7
|
+
import * as Clock from "effect/Clock";
|
|
7
8
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
9
|
import * as Effect from "effect/Effect";
|
|
9
10
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
@@ -56,6 +57,14 @@ function Make(Spec) {
|
|
|
56
57
|
return newrecord;
|
|
57
58
|
};
|
|
58
59
|
let Route = AutomationSlice_Route$ReventlessCore.Make(Spec)(Automation);
|
|
60
|
+
let recordedAtOf = time => {
|
|
61
|
+
let millis = new Date(time).getTime();
|
|
62
|
+
if (Number.isNaN(millis)) {
|
|
63
|
+
return;
|
|
64
|
+
} else {
|
|
65
|
+
return millis;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
59
68
|
let apply = param => {
|
|
60
69
|
param.collected.forEach(param => {
|
|
61
70
|
let id = param[0];
|
|
@@ -82,9 +91,18 @@ function Make(Spec) {
|
|
|
82
91
|
todoItems[id] = newrecord;
|
|
83
92
|
}));
|
|
84
93
|
};
|
|
85
|
-
let phase1 = (events,
|
|
94
|
+
let phase1 = (events, sliceCtx) => {
|
|
86
95
|
events.forEach(json => {
|
|
87
96
|
let context = Message$ReventlessCore.decode(json, Message$Reventless.contextSchema);
|
|
97
|
+
let recordedAt = recordedAtOf(context.meta.time);
|
|
98
|
+
let ctx;
|
|
99
|
+
if (recordedAt !== undefined) {
|
|
100
|
+
let newrecord = {...sliceCtx};
|
|
101
|
+
newrecord.recordedAt = recordedAt;
|
|
102
|
+
ctx = newrecord;
|
|
103
|
+
} else {
|
|
104
|
+
ctx = sliceCtx;
|
|
105
|
+
}
|
|
88
106
|
let sourceName = context.meta.service;
|
|
89
107
|
let dict = Stdlib_JSON.Decode.object(json);
|
|
90
108
|
let eventPayload = dict !== undefined ? Stdlib_Option.getOr(dict["event"], json) : json;
|
|
@@ -92,6 +110,7 @@ function Make(Spec) {
|
|
|
92
110
|
});
|
|
93
111
|
};
|
|
94
112
|
let phase2 = async publishJsons => {
|
|
113
|
+
let currentTime = await Effect.runPromise(Clock.currentTimeMillis);
|
|
95
114
|
let newlyAbandoned = [];
|
|
96
115
|
Object.entries(todoItems).forEach(param => {
|
|
97
116
|
let row = param[1];
|
|
@@ -128,7 +147,7 @@ function Make(Spec) {
|
|
|
128
147
|
item = undefined;
|
|
129
148
|
}
|
|
130
149
|
Stdlib_Option.forEach(item, item => {
|
|
131
|
-
let match = Automation.process(id, item);
|
|
150
|
+
let match = Automation.process(id, item, currentTime);
|
|
132
151
|
if (match === undefined) {
|
|
133
152
|
return;
|
|
134
153
|
}
|
|
@@ -111,7 +111,7 @@ module Make = (Spec: Spec) => {
|
|
|
111
111
|
| PluginExtensionPointSpec.CreateDisconnectSchedule(id, timeout) =>
|
|
112
112
|
await createSchedule({
|
|
113
113
|
name: id->disconnectScheduleName,
|
|
114
|
-
|
|
114
|
+
time: timeout->ScheduleOps.minutesFromNow,
|
|
115
115
|
payload: {
|
|
116
116
|
Message.id,
|
|
117
117
|
meta: Message.generateMeta(~service=PluginExtensionPointSpec.name, ~user="Scheduler"),
|
|
@@ -108,7 +108,7 @@ function Make(Spec) {
|
|
|
108
108
|
let id = directive._0;
|
|
109
109
|
return await createSchedule({
|
|
110
110
|
name: disconnectScheduleName(id),
|
|
111
|
-
|
|
111
|
+
time: ScheduleOps$ReventlessCore.minutesFromNow(directive._1),
|
|
112
112
|
payload: JSON.stringify(Message$ReventlessCore.encodeCommand$p({
|
|
113
113
|
id: id,
|
|
114
114
|
meta: Message$ReventlessCore.generateMeta(PluginExtensionPointSpec$ReventlessInfra.name, undefined, "Scheduler", undefined, undefined, undefined, undefined, undefined),
|
package/src/util/ScheduleOps.res
CHANGED
|
@@ -1,63 +1,9 @@
|
|
|
1
1
|
open Reventless.Schedule
|
|
2
2
|
|
|
3
|
-
module DateCalc = {
|
|
4
|
-
/*** Immutable functions to calculate new Dates without mutation the arguments */
|
|
5
|
-
@new
|
|
6
|
-
external dateFromDate: Date.t => Date.t = "Date"
|
|
7
|
-
|
|
8
|
-
let addMinutes: (Date.t, int) => Date.t = (date, minutes) => {
|
|
9
|
-
let result = date->dateFromDate
|
|
10
|
-
result->Date.setUTCMinutes(result->Date.getUTCMinutes + minutes)
|
|
11
|
-
result
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
let addDays: (Date.t, int) => Date.t = (date, days) => {
|
|
15
|
-
let result = date->dateFromDate
|
|
16
|
-
result->Date.setUTCDate(result->Date.getUTCDate + days)
|
|
17
|
-
result
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
3
|
let forChannel = (~name, ~channelId, ~resourceNaming: ReventlessInfra.ResourceNaming.operations) =>
|
|
22
4
|
name->resourceNaming.validateName ++ ("-" ++ channelId->String.split("-")->Array.getUnsafe(1))
|
|
23
5
|
|
|
24
|
-
let minutesFromNow = minutes => {
|
|
25
|
-
let m = Date.make()->DateCalc.addMinutes(minutes)
|
|
26
|
-
Single(
|
|
27
|
-
m->Date.getUTCFullYear,
|
|
28
|
-
m->Date.getUTCMonth + 1,
|
|
29
|
-
m->Date.getUTCDate,
|
|
30
|
-
m->Date.getUTCHours,
|
|
31
|
-
m->Date.getUTCMinutes,
|
|
32
|
-
)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
let nextTime = (h: hour, m: minute) => {
|
|
36
|
-
let now = Date.make()
|
|
37
|
-
let today = {
|
|
38
|
-
let epoch = Date.make()
|
|
39
|
-
epoch->Date.setUTCHours(h)
|
|
40
|
-
epoch->Date.setUTCMinutes(m)
|
|
41
|
-
epoch->Date.setUTCSeconds(0)
|
|
42
|
-
epoch
|
|
43
|
-
}
|
|
44
|
-
let tomorrow = today->DateCalc.addDays(1)
|
|
45
|
-
today < now
|
|
46
|
-
? Single(
|
|
47
|
-
tomorrow->Date.getUTCFullYear,
|
|
48
|
-
tomorrow->Date.getUTCMonth + 1,
|
|
49
|
-
tomorrow->Date.getUTCDate,
|
|
50
|
-
tomorrow->Date.getUTCHours,
|
|
51
|
-
tomorrow->Date.getUTCMinutes,
|
|
52
|
-
)
|
|
53
|
-
: Single(
|
|
54
|
-
today->Date.getUTCFullYear,
|
|
55
|
-
today->Date.getUTCMonth + 1,
|
|
56
|
-
today->Date.getUTCDate,
|
|
57
|
-
today->Date.getUTCHours,
|
|
58
|
-
today->Date.getUTCMinutes,
|
|
59
|
-
)
|
|
60
|
-
}
|
|
6
|
+
let minutesFromNow = minutes => Once({moment: After(Minutes(minutes))})
|
|
61
7
|
|
|
62
8
|
exception ScheduleNotCreated(schedule)
|
|
63
9
|
exception ScheduleNotDeleted(string)
|
|
@@ -6,72 +6,27 @@ import * as Exit$1 from "effect/Exit";
|
|
|
6
6
|
import * as Cause from "effect/Cause";
|
|
7
7
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
8
|
import * as Effect$1 from "effect/Effect";
|
|
9
|
-
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
10
9
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
11
10
|
import * as Util_Error$ReventlessCore from "./Util_Error.res.mjs";
|
|
12
11
|
import * as EffectLogger$ReventlessCore from "./EffectLogger.res.mjs";
|
|
13
12
|
|
|
14
|
-
function addMinutes(date, minutes) {
|
|
15
|
-
let result = new Date(date);
|
|
16
|
-
result.setUTCMinutes(result.getUTCMinutes() + minutes | 0);
|
|
17
|
-
return result;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function addDays(date, days) {
|
|
21
|
-
let result = new Date(date);
|
|
22
|
-
result.setUTCDate(result.getUTCDate() + days | 0);
|
|
23
|
-
return result;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
let DateCalc = {
|
|
27
|
-
addMinutes: addMinutes,
|
|
28
|
-
addDays: addDays
|
|
29
|
-
};
|
|
30
|
-
|
|
31
13
|
function forChannel(name, channelId, resourceNaming) {
|
|
32
14
|
return resourceNaming.validateName(name) + ("-" + channelId.split("-")[1]);
|
|
33
15
|
}
|
|
34
16
|
|
|
35
17
|
function minutesFromNow(minutes) {
|
|
36
|
-
let m = addMinutes(new Date(), minutes);
|
|
37
18
|
return {
|
|
38
|
-
TAG: "
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
19
|
+
TAG: "Once",
|
|
20
|
+
moment: {
|
|
21
|
+
TAG: "After",
|
|
22
|
+
_0: {
|
|
23
|
+
TAG: "Minutes",
|
|
24
|
+
_0: minutes
|
|
25
|
+
}
|
|
26
|
+
}
|
|
44
27
|
};
|
|
45
28
|
}
|
|
46
29
|
|
|
47
|
-
function nextTime(h, m) {
|
|
48
|
-
let now = new Date();
|
|
49
|
-
let epoch = new Date();
|
|
50
|
-
epoch.setUTCHours(h);
|
|
51
|
-
epoch.setUTCMinutes(m);
|
|
52
|
-
epoch.setUTCSeconds(0);
|
|
53
|
-
let tomorrow = addDays(epoch, 1);
|
|
54
|
-
if (Primitive_object.lessthan(epoch, now)) {
|
|
55
|
-
return {
|
|
56
|
-
TAG: "Single",
|
|
57
|
-
_0: tomorrow.getUTCFullYear(),
|
|
58
|
-
_1: tomorrow.getUTCMonth() + 1 | 0,
|
|
59
|
-
_2: tomorrow.getUTCDate(),
|
|
60
|
-
_3: tomorrow.getUTCHours(),
|
|
61
|
-
_4: tomorrow.getUTCMinutes()
|
|
62
|
-
};
|
|
63
|
-
} else {
|
|
64
|
-
return {
|
|
65
|
-
TAG: "Single",
|
|
66
|
-
_0: epoch.getUTCFullYear(),
|
|
67
|
-
_1: epoch.getUTCMonth() + 1 | 0,
|
|
68
|
-
_2: epoch.getUTCDate(),
|
|
69
|
-
_3: epoch.getUTCHours(),
|
|
70
|
-
_4: epoch.getUTCMinutes()
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
30
|
let ScheduleNotCreated = /* @__PURE__ */Primitive_exceptions.create("ScheduleOps-ReventlessCore.ScheduleNotCreated");
|
|
76
31
|
|
|
77
32
|
let ScheduleNotDeleted = /* @__PURE__ */Primitive_exceptions.create("ScheduleOps-ReventlessCore.ScheduleNotDeleted");
|
|
@@ -79,11 +34,11 @@ let ScheduleNotDeleted = /* @__PURE__ */Primitive_exceptions.create("ScheduleOps
|
|
|
79
34
|
function create(scheduler, channelResources, resourceNaming) {
|
|
80
35
|
return async schedule => {
|
|
81
36
|
let name = resourceNaming.validateName(schedule.name);
|
|
82
|
-
let
|
|
37
|
+
let schedule_time = schedule.time;
|
|
83
38
|
let schedule_payload = schedule.payload;
|
|
84
39
|
let schedule$1 = {
|
|
85
40
|
name: name,
|
|
86
|
-
|
|
41
|
+
time: schedule_time,
|
|
87
42
|
payload: schedule_payload
|
|
88
43
|
};
|
|
89
44
|
let createSchedule = scheduler.createSchedule;
|
|
@@ -120,10 +75,8 @@ function $$delete(scheduler, channelResources, resourceNaming) {
|
|
|
120
75
|
}
|
|
121
76
|
|
|
122
77
|
export {
|
|
123
|
-
DateCalc,
|
|
124
78
|
forChannel,
|
|
125
79
|
minutesFromNow,
|
|
126
|
-
nextTime,
|
|
127
80
|
ScheduleNotCreated,
|
|
128
81
|
ScheduleNotDeleted,
|
|
129
82
|
create,
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// When a `Schedule.time` fires, read the same way by the local timers and the AWS
|
|
2
|
+
// expression, so the two platforms agree. A calendar time in the hour skipped by
|
|
3
|
+
// daylight saving does not fire that day, as EventBridge Scheduler does.
|
|
4
|
+
open Reventless.Schedule
|
|
5
|
+
|
|
6
|
+
exception InvalidTime(string)
|
|
7
|
+
|
|
8
|
+
let utc = "UTC"
|
|
9
|
+
let zoneName = (zone: option<zone>) => zone->Option.getOr(utc)
|
|
10
|
+
|
|
11
|
+
let zoneOrThrow = name =>
|
|
12
|
+
switch DateTime.zoneMakeNamed(name) {
|
|
13
|
+
| Some(zone) => zone
|
|
14
|
+
| None => throw(InvalidTime(`unknown time zone "${name}"`))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let minuteMs = 60_000.
|
|
18
|
+
|
|
19
|
+
let intervalMs = (interval: interval) =>
|
|
20
|
+
switch interval {
|
|
21
|
+
| Minutes(n) => n->Int.toFloat *. minuteMs
|
|
22
|
+
| Hours(n) => n->Int.toFloat *. 60. *. minuteMs
|
|
23
|
+
| Days(n) => n->Int.toFloat *. 24. *. 60. *. minuteMs
|
|
24
|
+
| Weeks(n) => n->Int.toFloat *. 7. *. 24. *. 60. *. minuteMs
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let weekdayIndex = (day: weekday) =>
|
|
28
|
+
switch day {
|
|
29
|
+
| Sun => 0
|
|
30
|
+
| Mon => 1
|
|
31
|
+
| Tue => 2
|
|
32
|
+
| Wed => 3
|
|
33
|
+
| Thu => 4
|
|
34
|
+
| Fri => 5
|
|
35
|
+
| Sat => 6
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let weekdayName = (day: weekday) =>
|
|
39
|
+
switch day {
|
|
40
|
+
| Mon => "MON"
|
|
41
|
+
| Tue => "TUE"
|
|
42
|
+
| Wed => "WED"
|
|
43
|
+
| Thu => "THU"
|
|
44
|
+
| Fri => "FRI"
|
|
45
|
+
| Sat => "SAT"
|
|
46
|
+
| Sun => "SUN"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let errors = (time: time) => {
|
|
50
|
+
let errs = []
|
|
51
|
+
let check = (ok, message) => ok ? () : errs->Array.push(message)
|
|
52
|
+
let positive = (n, what) => check(n >= 1, `${what} must be at least 1, got ${n->Int.toString}`)
|
|
53
|
+
let clock = (hour, minute) => {
|
|
54
|
+
check(hour >= 0 && hour <= 23, `hour must be 0 to 23, got ${hour->Int.toString}`)
|
|
55
|
+
check(minute >= 0 && minute <= 59, `minute must be 0 to 59, got ${minute->Int.toString}`)
|
|
56
|
+
}
|
|
57
|
+
let dayOfMonth = day =>
|
|
58
|
+
check(day >= 1 && day <= 31, `day must be 1 to 31, got ${day->Int.toString}`)
|
|
59
|
+
let monthOfYear = month =>
|
|
60
|
+
check(month >= 1 && month <= 12, `month must be 1 to 12, got ${month->Int.toString}`)
|
|
61
|
+
let zoneKnown = zone =>
|
|
62
|
+
zone->Option.forEach(name =>
|
|
63
|
+
check(DateTime.zoneMakeNamed(name)->Option.isSome, `unknown time zone "${name}"`)
|
|
64
|
+
)
|
|
65
|
+
switch time {
|
|
66
|
+
| Every(Minutes(n) | Hours(n) | Days(n) | Weeks(n)) => positive(n, "an interval")
|
|
67
|
+
| Calendar({calendar, ?zone}) =>
|
|
68
|
+
zoneKnown(zone)
|
|
69
|
+
switch calendar {
|
|
70
|
+
| Daily({hour, minute}) => clock(hour, minute)
|
|
71
|
+
| Weekly({days, hour, minute}) =>
|
|
72
|
+
check(days->Array.length > 0, "a weekly time needs at least one day")
|
|
73
|
+
clock(hour, minute)
|
|
74
|
+
| Monthly({day, hour, minute, ?every}) =>
|
|
75
|
+
dayOfMonth(day)
|
|
76
|
+
clock(hour, minute)
|
|
77
|
+
every->Option.forEach(every =>
|
|
78
|
+
check(every >= 1 && every <= 12, `every must be 1 to 12, got ${every->Int.toString}`)
|
|
79
|
+
)
|
|
80
|
+
| Yearly({month, day, hour, minute}) =>
|
|
81
|
+
monthOfYear(month)
|
|
82
|
+
dayOfMonth(day)
|
|
83
|
+
clock(hour, minute)
|
|
84
|
+
}
|
|
85
|
+
| Once({moment, ?zone}) =>
|
|
86
|
+
zoneKnown(zone)
|
|
87
|
+
switch moment {
|
|
88
|
+
| At({month, day, hour, minute}) =>
|
|
89
|
+
monthOfYear(month)
|
|
90
|
+
dayOfMonth(day)
|
|
91
|
+
clock(hour, minute)
|
|
92
|
+
| After(Minutes(n) | Hours(n) | Days(n) | Weeks(n) | Months(n) | Years(n)) =>
|
|
93
|
+
positive(n, "a period")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
errs
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Throws `InvalidTime` naming every problem of `time`, for a schedule about to be set. */
|
|
100
|
+
let validate = time =>
|
|
101
|
+
switch time->errors {
|
|
102
|
+
| [] => ()
|
|
103
|
+
| errs => throw(InvalidTime(errs->Array.join("; ")))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let localTimeOf = (millis, ~zone): localTime => {
|
|
107
|
+
let parts = DateTime.makeZonedFromMillis(millis, ~zone)->DateTime.toParts
|
|
108
|
+
{
|
|
109
|
+
year: parts.year,
|
|
110
|
+
month: parts.month,
|
|
111
|
+
day: parts.day,
|
|
112
|
+
hour: parts.hours,
|
|
113
|
+
minute: parts.minutes,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
The moment a period after `from` falls on, in `zone`. Minutes and hours are elapsed time;
|
|
119
|
+
days and longer keep the wall-clock time, and a month from 31 January is the last day
|
|
120
|
+
of February.
|
|
121
|
+
*/
|
|
122
|
+
let afterPeriod = (period: period, ~from, ~zone) => {
|
|
123
|
+
let start = DateTime.makeZonedFromMillis(from, ~zone)
|
|
124
|
+
switch period {
|
|
125
|
+
| Minutes(n) => from +. n->Int.toFloat *. minuteMs
|
|
126
|
+
| Hours(n) => from +. n->Int.toFloat *. 60. *. minuteMs
|
|
127
|
+
| Days(n) => start->DateTime.add({days: n})->DateTime.toEpochMillis
|
|
128
|
+
| Weeks(n) => start->DateTime.add({weeks: n})->DateTime.toEpochMillis
|
|
129
|
+
| Months(n) => start->DateTime.add({months: n})->DateTime.toEpochMillis
|
|
130
|
+
| Years(n) => start->DateTime.add({years: n})->DateTime.toEpochMillis
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `time` with an `After` turned into the `At` it falls on when counted from `from`. */
|
|
135
|
+
let resolveOnce = (time, ~from) =>
|
|
136
|
+
switch time {
|
|
137
|
+
| Once({moment: After(period), ?zone}) =>
|
|
138
|
+
let tz = zoneOrThrow(zone->zoneName)
|
|
139
|
+
Once({moment: At(afterPeriod(period, ~from, ~zone=tz)->localTimeOf(~zone=tz)), ?zone})
|
|
140
|
+
| time => time
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The instant an `At` falls on; a time in the skipped hour moves forward by the gap. */
|
|
144
|
+
let instantOf = ({year, month, day, hour, minute}: localTime, ~zone) =>
|
|
145
|
+
switch DateTime.makeZonedFromParts({year, month, day, hours: hour, minutes: minute}, ~zone) {
|
|
146
|
+
| Some(dateTime) => dateTime->DateTime.toEpochMillis
|
|
147
|
+
| None =>
|
|
148
|
+
throw(
|
|
149
|
+
InvalidTime(
|
|
150
|
+
`no such time: ${year->Int.toString}-${month->Int.toString}-${day->Int.toString}`,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let wallClockOf = (calendar: calendar) =>
|
|
156
|
+
switch calendar {
|
|
157
|
+
| Daily({hour, minute})
|
|
158
|
+
| Weekly({hour, minute})
|
|
159
|
+
| Monthly({hour, minute})
|
|
160
|
+
| Yearly({hour, minute}) => (hour, minute)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let monthsEvery = every =>
|
|
164
|
+
every <= 1 ? [] : Array.fromInitializer(~length=11 / every + 1, i => 1 + i * every)
|
|
165
|
+
|
|
166
|
+
let cronOf = (calendar: calendar, ~zone): Cron.t => {
|
|
167
|
+
let (hour, minute) = calendar->wallClockOf
|
|
168
|
+
let at = (~days=[], ~months=[], ~weekdays=[]) =>
|
|
169
|
+
Cron.make({minutes: [minute], hours: [hour], days, months, weekdays, tz: zone})
|
|
170
|
+
switch calendar {
|
|
171
|
+
| Daily(_) => at()
|
|
172
|
+
| Weekly({days}) => at(~weekdays=days->Array.map(weekdayIndex))
|
|
173
|
+
| Monthly({day, ?every}) => at(~days=[day], ~months=every->Option.getOr(1)->monthsEvery)
|
|
174
|
+
| Yearly({month, day}) => at(~days=[day], ~months=[month])
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
The first firing of `cron` strictly after `after` whose wall-clock time is
|
|
180
|
+
`hour:minute`: a time the skipped hour moved is passed over.
|
|
181
|
+
*/
|
|
182
|
+
let nextOnWallClock = (cron, ~after, ~hour, ~minute, ~zone) => {
|
|
183
|
+
let rec loop = (from, tries) => {
|
|
184
|
+
let next = cron->Cron.next(Date.fromTime(from))->Date.getTime
|
|
185
|
+
let parts = DateTime.makeZonedFromMillis(next, ~zone)->DateTime.toParts
|
|
186
|
+
(parts.hours == hour && parts.minutes == minute) || tries >= 8 ? next : loop(next, tries + 1)
|
|
187
|
+
}
|
|
188
|
+
loop(after, 0)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
The first firing of `time` strictly after `after`, or `None` when a once-only time is
|
|
193
|
+
already past. For `Every`, `after` is the previous firing, or the moment it was set.
|
|
194
|
+
*/
|
|
195
|
+
let nextFiring = (time, ~after) =>
|
|
196
|
+
switch time->resolveOnce(~from=after) {
|
|
197
|
+
| Every(interval) => Some(after +. interval->intervalMs)
|
|
198
|
+
| Calendar({calendar, ?zone}) =>
|
|
199
|
+
let tz = zoneOrThrow(zone->zoneName)
|
|
200
|
+
let (hour, minute) = calendar->wallClockOf
|
|
201
|
+
Some(calendar->cronOf(~zone=tz)->nextOnWallClock(~after, ~hour, ~minute, ~zone=tz))
|
|
202
|
+
| Once({moment: At(localTime), ?zone}) =>
|
|
203
|
+
let at = localTime->instantOf(~zone=zoneOrThrow(zone->zoneName))
|
|
204
|
+
at > after ? Some(at) : None
|
|
205
|
+
| Once({moment: After(_)}) => None
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What EventBridge Scheduler is given for a time. */
|
|
209
|
+
type awsSchedule = {
|
|
210
|
+
expression: string,
|
|
211
|
+
timezone: option<string>,
|
|
212
|
+
startDate: option<float>,
|
|
213
|
+
deleteAfterCompletion: bool,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let pad2 = n => n < 10 ? `0${n->Int.toString}` : n->Int.toString
|
|
217
|
+
|
|
218
|
+
let rateOf = (n, unit) => `rate(${n->Int.toString} ${unit}${n == 1 ? "" : "s"})`
|
|
219
|
+
|
|
220
|
+
let cronExpressionOf = (calendar: calendar) => {
|
|
221
|
+
let (hour, minute) = calendar->wallClockOf
|
|
222
|
+
let fields = switch calendar {
|
|
223
|
+
| Daily(_) => "* * ? *"
|
|
224
|
+
| Weekly({days}) => `? * ${days->Array.map(weekdayName)->Array.join(",")} *`
|
|
225
|
+
| Monthly({day, ?every}) =>
|
|
226
|
+
let month = switch every {
|
|
227
|
+
| Some(every) if every > 1 => `1/${every->Int.toString}`
|
|
228
|
+
| _ => "*"
|
|
229
|
+
}
|
|
230
|
+
`${day->Int.toString} ${month} ? *`
|
|
231
|
+
| Yearly({month, day}) => `${day->Int.toString} ${month->Int.toString} ? *`
|
|
232
|
+
}
|
|
233
|
+
`cron(${minute->Int.toString} ${hour->Int.toString} ${fields})`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
The EventBridge Scheduler form of `time`, set at `setAt`. An `Every` starts one interval
|
|
238
|
+
later, as the local timer does; without a start date AWS fires at once.
|
|
239
|
+
*/
|
|
240
|
+
let toAws = (time, ~setAt) =>
|
|
241
|
+
switch time->resolveOnce(~from=setAt) {
|
|
242
|
+
| Every(interval) => {
|
|
243
|
+
expression: switch interval {
|
|
244
|
+
| Minutes(n) => rateOf(n, "minute")
|
|
245
|
+
| Hours(n) => rateOf(n, "hour")
|
|
246
|
+
| Days(n) => rateOf(n, "day")
|
|
247
|
+
| Weeks(n) => rateOf(n * 7, "day")
|
|
248
|
+
},
|
|
249
|
+
timezone: None,
|
|
250
|
+
startDate: Some(setAt +. interval->intervalMs),
|
|
251
|
+
deleteAfterCompletion: false,
|
|
252
|
+
}
|
|
253
|
+
| Calendar({calendar, ?zone}) => {
|
|
254
|
+
expression: calendar->cronExpressionOf,
|
|
255
|
+
timezone: Some(zone->zoneName),
|
|
256
|
+
startDate: None,
|
|
257
|
+
deleteAfterCompletion: false,
|
|
258
|
+
}
|
|
259
|
+
| Once({moment: At({year, month, day, hour, minute}), ?zone}) => {
|
|
260
|
+
expression: `at(${year->Int.toString}-${month->pad2}-${day->pad2}T${hour->pad2}:${minute->pad2}:00)`,
|
|
261
|
+
timezone: Some(zone->zoneName),
|
|
262
|
+
startDate: None,
|
|
263
|
+
deleteAfterCompletion: true,
|
|
264
|
+
}
|
|
265
|
+
| Once({moment: After(_)}) => throw(InvalidTime("unresolved once-only time"))
|
|
266
|
+
}
|