@reventlessdev/reventless-core 3.0.0-alpha.294 → 3.0.0-alpha.296
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 +39 -0
- package/package.json +8 -8
- package/src/admin/Platform_ComponentDefinitionsApi.res +3 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
- package/src/components/AutomationSlice/AutomationSlice.res +2 -0
- package/src/components/AutomationSlice/AutomationSlice_Builder.res +133 -8
- package/src/components/AutomationSlice/AutomationSlice_Builder.res.mjs +89 -7
- package/src/components/AutomationSlice/AutomationSlice_Callback.res +136 -3
- package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +122 -4
- package/src/components/AutomationSlice/AutomationSlice_Route.res +3 -1
- package/src/components/AutomationSlice/AutomationSlice_Route.res.mjs +2 -1
- package/src/components/AutomationSlice/AutomationSlice_Schedules.res +149 -0
- package/src/components/AutomationSlice/AutomationSlice_Schedules.res.mjs +193 -0
- package/src/components/Dcb/Dcb_Builder.res +28 -4
- package/src/components/Dcb/Dcb_Builder.res.mjs +27 -5
- package/src/plugin/component/Plugin_Builder.res +10 -3
- package/src/plugin/component/Plugin_Builder.res.mjs +8 -2
- package/src/plugin/component/Plugin_Structure.res +7 -0
- package/src/plugin/component/Plugin_Structure.res.mjs +5 -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/admin/AdminApiSchemaDriftTest.res +1 -0
- package/tests/admin/AdminApiSchemaDriftTest.res.mjs +4 -1
- package/tests/automationslice/AutomationSliceSchedulesTest.res +143 -0
- package/tests/automationslice/AutomationSliceSchedulesTest.res.mjs +247 -0
- package/tests/util/ScheduleTimeTest.res +256 -0
- package/tests/util/ScheduleTimeTest.res.mjs +532 -0
|
@@ -120,6 +120,7 @@ let automation: automationSliceDef = {
|
|
|
120
120
|
producedCommandTypes: ["Restock"],
|
|
121
121
|
targetName: "Product",
|
|
122
122
|
chapter: Some("Replenishment"),
|
|
123
|
+
schedule: `{"TAG":"Every","_0":{"TAG":"Minutes","_0":15}}`,
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
let outbound: outboundTranslationSliceDef = {
|
|
@@ -184,12 +184,15 @@ let automation_producedCommandTypes = ["Restock"];
|
|
|
184
184
|
|
|
185
185
|
let automation_chapter = "Replenishment";
|
|
186
186
|
|
|
187
|
+
let automation_schedule = `{"TAG":"Every","_0":{"TAG":"Minutes","_0":15}}`;
|
|
188
|
+
|
|
187
189
|
let automation = {
|
|
188
190
|
name: "Restocker",
|
|
189
191
|
consumedEventTypes: automation_consumedEventTypes,
|
|
190
192
|
producedCommandTypes: automation_producedCommandTypes,
|
|
191
193
|
targetName: "Product",
|
|
192
|
-
chapter: automation_chapter
|
|
194
|
+
chapter: automation_chapter,
|
|
195
|
+
schedule: automation_schedule
|
|
193
196
|
};
|
|
194
197
|
|
|
195
198
|
let outbound_consumedEventTypes = ["OrderPlaced"];
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// A slice's schedule rows, and how the platform is brought in line with them: the
|
|
2
|
+
// declared time is written unless an event set or cleared the slice-wide key, the
|
|
3
|
+
// platform gets what it is missing and loses what the rows do not hold, and a
|
|
4
|
+
// once-only time already past is not put again.
|
|
5
|
+
|
|
6
|
+
open JestGlobals
|
|
7
|
+
|
|
8
|
+
module Schedules = AutomationSlice_Schedules
|
|
9
|
+
|
|
10
|
+
let at = iso => Date.fromString(iso)->Date.getTime
|
|
11
|
+
let daily6 = Reventless.Schedule.Calendar({calendar: Daily({hour: 6, minute: 0})})
|
|
12
|
+
let daily18 = Reventless.Schedule.Calendar({calendar: Daily({hour: 18, minute: 0})})
|
|
13
|
+
let now = at("2026-10-16T12:00:00Z")
|
|
14
|
+
|
|
15
|
+
// A platform that holds its schedules in a dict and records what it was told.
|
|
16
|
+
let fakePlatform = (~held: array<string>=[]) => {
|
|
17
|
+
let schedules = Dict.fromArray(held->Array.map(name => (name, daily6)))
|
|
18
|
+
let calls = []
|
|
19
|
+
let platform: ReventlessInfra.AutomationSlice.platformSchedules = {
|
|
20
|
+
put: async (~key, time) => {
|
|
21
|
+
calls->Array.push(`put ${key}`)
|
|
22
|
+
schedules->Dict.set(key, time)
|
|
23
|
+
},
|
|
24
|
+
removeName: async name => {
|
|
25
|
+
calls->Array.push(`remove ${name}`)
|
|
26
|
+
schedules->Dict.delete(name)
|
|
27
|
+
},
|
|
28
|
+
names: async () => schedules->Dict.keysToArray,
|
|
29
|
+
nameOf: key => key,
|
|
30
|
+
}
|
|
31
|
+
(platform, schedules, calls)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let liveKeys = rows =>
|
|
35
|
+
rows
|
|
36
|
+
->Dict.valuesToArray
|
|
37
|
+
->Array.filter(row => row.Schedules.time->Option.isSome)
|
|
38
|
+
->Array.map(row => row.key)
|
|
39
|
+
->Array.toSorted(String.compare)
|
|
40
|
+
|
|
41
|
+
let set = (rows, key, time, ~recordedAt=now) =>
|
|
42
|
+
rows->Schedules.apply(Set({key, time}), ~setBy="event", ~recordedAt)
|
|
43
|
+
let clear = (rows, key) => rows->Schedules.apply(Clear({key: key}), ~setBy="event", ~recordedAt=now)
|
|
44
|
+
|
|
45
|
+
describe("the declared time", () => {
|
|
46
|
+
testSync("is written when no event has touched the slice-wide key", () => {
|
|
47
|
+
let rows = Dict.make()
|
|
48
|
+
rows->Schedules.declare(Some(daily6), ~now)
|
|
49
|
+
expect(rows->Dict.get("default")->Option.flatMap(Schedules.timeOf))->toEqual(Some(daily6))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
testSync("does not replace a time an event set", () => {
|
|
53
|
+
let rows = Dict.make()
|
|
54
|
+
let _ = rows->set("default", daily18)
|
|
55
|
+
rows->Schedules.declare(Some(daily6), ~now)
|
|
56
|
+
expect(rows->Dict.get("default")->Option.flatMap(Schedules.timeOf))->toEqual(Some(daily18))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
testSync("does not come back after an event cleared it", () => {
|
|
60
|
+
let rows = Dict.make()
|
|
61
|
+
rows->Schedules.declare(Some(daily6), ~now)
|
|
62
|
+
let _ = rows->clear("default")
|
|
63
|
+
rows->Schedules.declare(Some(daily6), ~now)
|
|
64
|
+
expect(rows->liveKeys)->toEqual([])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
testSync("follows a changed declaration, and goes with it", () => {
|
|
68
|
+
let rows = Dict.make()
|
|
69
|
+
rows->Schedules.declare(Some(daily6), ~now)
|
|
70
|
+
rows->Schedules.declare(Some(daily18), ~now)
|
|
71
|
+
let changed = rows->Dict.get("default")->Option.flatMap(Schedules.timeOf)
|
|
72
|
+
rows->Schedules.declare(None, ~now)
|
|
73
|
+
expect((changed, rows->liveKeys))->toEqual((Some(daily18), []))
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("a change an event makes", () => {
|
|
78
|
+
testSync("counts an After from when the event was recorded", () => {
|
|
79
|
+
let rows = Dict.make()
|
|
80
|
+
let _ =
|
|
81
|
+
rows->set("o1", Once({moment: After(Minutes(10))}), ~recordedAt=at("2026-10-16T09:00:00Z"))
|
|
82
|
+
expect(rows->Dict.get("o1")->Option.flatMap(Schedules.timeOf))->toEqual(
|
|
83
|
+
Some(
|
|
84
|
+
Reventless.Schedule.Once({
|
|
85
|
+
moment: At({year: 2026, month: 10, day: 16, hour: 9, minute: 10}),
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
testSync("refuses a time that cannot fire, and changes nothing", () => {
|
|
92
|
+
let rows = Dict.make()
|
|
93
|
+
let refused = rows->set("r1", Calendar({calendar: Daily({hour: 25, minute: 0})}))
|
|
94
|
+
expect((refused->Result.isError, rows->liveKeys))->toEqual((true, []))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
testSync("removes a once-only schedule when it fires, not a recurring one", () => {
|
|
98
|
+
let rows = Dict.make()
|
|
99
|
+
let _ = rows->set("o1", Once({moment: After(Minutes(10))}))
|
|
100
|
+
let _ = rows->set("r1", daily18)
|
|
101
|
+
rows->Schedules.fired(~key="o1")
|
|
102
|
+
rows->Schedules.fired(~key="r1")
|
|
103
|
+
expect(rows->liveKeys)->toEqual(["r1"])
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe("the reconcile", () => {
|
|
108
|
+
testPromise(
|
|
109
|
+
"puts what the platform is missing and removes what the rows do not hold",
|
|
110
|
+
async () => {
|
|
111
|
+
let rows = Dict.make()
|
|
112
|
+
let _ = rows->set("r1", daily18)
|
|
113
|
+
let _ = rows->set("r2", daily18)
|
|
114
|
+
let (platform, held, _) = fakePlatform(~held=["r2", "stray"])
|
|
115
|
+
await rows->Schedules.reconcile(platform, ~declaredTime=None, ~now)
|
|
116
|
+
expect(held->Dict.keysToArray->Array.toSorted(String.compare))->toEqual(["r1", "r2"])
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
testPromise("does not put again a schedule an event set and the platform holds", async () => {
|
|
121
|
+
let rows = Dict.make()
|
|
122
|
+
let _ = rows->set("r1", daily18)
|
|
123
|
+
let (platform, _, calls) = fakePlatform(~held=["r1"])
|
|
124
|
+
await rows->Schedules.reconcile(platform, ~declaredTime=None, ~now)
|
|
125
|
+
expect(calls)->toEqual([])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
testPromise("puts the declared time again, in case it changed", async () => {
|
|
129
|
+
let rows = Dict.make()
|
|
130
|
+
let (platform, _, calls) = fakePlatform(~held=["default"])
|
|
131
|
+
await rows->Schedules.reconcile(platform, ~declaredTime=Some(daily6), ~now)
|
|
132
|
+
expect(calls)->toEqual(["put default"])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
testPromise("drops a once-only time already past, from the rows and the platform", async () => {
|
|
136
|
+
let rows = Dict.make()
|
|
137
|
+
let _ =
|
|
138
|
+
rows->set("o1", Once({moment: After(Minutes(10))}), ~recordedAt=at("2026-10-16T09:00:00Z"))
|
|
139
|
+
let (platform, held, _) = fakePlatform(~held=["o1"])
|
|
140
|
+
await rows->Schedules.reconcile(platform, ~declaredTime=None, ~now)
|
|
141
|
+
expect((rows->liveKeys, held->Dict.keysToArray))->toEqual(([], []))
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
6
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
7
|
+
import * as AutomationSlice_Schedules$ReventlessCore from "../../src/components/AutomationSlice/AutomationSlice_Schedules.res.mjs";
|
|
8
|
+
|
|
9
|
+
function at(iso) {
|
|
10
|
+
return new Date(iso).getTime();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let daily6 = {
|
|
14
|
+
TAG: "Calendar",
|
|
15
|
+
calendar: {
|
|
16
|
+
TAG: "Daily",
|
|
17
|
+
hour: 6,
|
|
18
|
+
minute: 0
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let daily18 = {
|
|
23
|
+
TAG: "Calendar",
|
|
24
|
+
calendar: {
|
|
25
|
+
TAG: "Daily",
|
|
26
|
+
hour: 18,
|
|
27
|
+
minute: 0
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
let now = new Date("2026-10-16T12:00:00Z").getTime();
|
|
32
|
+
|
|
33
|
+
function fakePlatform(heldOpt) {
|
|
34
|
+
let held = heldOpt !== undefined ? heldOpt : [];
|
|
35
|
+
let schedules = Object.fromEntries(held.map(name => [
|
|
36
|
+
name,
|
|
37
|
+
daily6
|
|
38
|
+
]));
|
|
39
|
+
let calls = [];
|
|
40
|
+
let platform_put = async (key, time) => {
|
|
41
|
+
calls.push(`put ` + key);
|
|
42
|
+
schedules[key] = time;
|
|
43
|
+
};
|
|
44
|
+
let platform_removeName = async name => {
|
|
45
|
+
calls.push(`remove ` + name);
|
|
46
|
+
return Stdlib_Dict.$$delete(schedules, name);
|
|
47
|
+
};
|
|
48
|
+
let platform_names = async () => Object.keys(schedules);
|
|
49
|
+
let platform_nameOf = key => key;
|
|
50
|
+
let platform = {
|
|
51
|
+
put: platform_put,
|
|
52
|
+
removeName: platform_removeName,
|
|
53
|
+
names: platform_names,
|
|
54
|
+
nameOf: platform_nameOf
|
|
55
|
+
};
|
|
56
|
+
return [
|
|
57
|
+
platform,
|
|
58
|
+
schedules,
|
|
59
|
+
calls
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function liveKeys(rows) {
|
|
64
|
+
return Object.values(rows).filter(row => Stdlib_Option.isSome(row.time)).map(row => row.key).toSorted(Primitive_string.compare);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function set(rows, key, time, recordedAtOpt) {
|
|
68
|
+
let recordedAt = recordedAtOpt !== undefined ? recordedAtOpt : now;
|
|
69
|
+
return AutomationSlice_Schedules$ReventlessCore.apply(rows, {
|
|
70
|
+
TAG: "Set",
|
|
71
|
+
key: key,
|
|
72
|
+
time: time
|
|
73
|
+
}, "event", recordedAt);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clear(rows, key) {
|
|
77
|
+
return AutomationSlice_Schedules$ReventlessCore.apply(rows, {
|
|
78
|
+
TAG: "Clear",
|
|
79
|
+
key: key
|
|
80
|
+
}, "event", now);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
globalThis.describe("the declared time", () => {
|
|
84
|
+
globalThis.test("is written when no event has touched the slice-wide key", () => {
|
|
85
|
+
let rows = {};
|
|
86
|
+
AutomationSlice_Schedules$ReventlessCore.declare(rows, daily6, now);
|
|
87
|
+
globalThis.expect(Stdlib_Option.flatMap(rows["default"], AutomationSlice_Schedules$ReventlessCore.timeOf)).toEqual(daily6);
|
|
88
|
+
});
|
|
89
|
+
globalThis.test("does not replace a time an event set", () => {
|
|
90
|
+
let rows = {};
|
|
91
|
+
set(rows, "default", daily18, undefined);
|
|
92
|
+
AutomationSlice_Schedules$ReventlessCore.declare(rows, daily6, now);
|
|
93
|
+
globalThis.expect(Stdlib_Option.flatMap(rows["default"], AutomationSlice_Schedules$ReventlessCore.timeOf)).toEqual(daily18);
|
|
94
|
+
});
|
|
95
|
+
globalThis.test("does not come back after an event cleared it", () => {
|
|
96
|
+
let rows = {};
|
|
97
|
+
AutomationSlice_Schedules$ReventlessCore.declare(rows, daily6, now);
|
|
98
|
+
clear(rows, "default");
|
|
99
|
+
AutomationSlice_Schedules$ReventlessCore.declare(rows, daily6, now);
|
|
100
|
+
globalThis.expect(liveKeys(rows)).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
globalThis.test("follows a changed declaration, and goes with it", () => {
|
|
103
|
+
let rows = {};
|
|
104
|
+
AutomationSlice_Schedules$ReventlessCore.declare(rows, daily6, now);
|
|
105
|
+
AutomationSlice_Schedules$ReventlessCore.declare(rows, daily18, now);
|
|
106
|
+
let changed = Stdlib_Option.flatMap(rows["default"], AutomationSlice_Schedules$ReventlessCore.timeOf);
|
|
107
|
+
AutomationSlice_Schedules$ReventlessCore.declare(rows, undefined, now);
|
|
108
|
+
globalThis.expect([
|
|
109
|
+
changed,
|
|
110
|
+
liveKeys(rows)
|
|
111
|
+
]).toEqual([
|
|
112
|
+
daily18,
|
|
113
|
+
[]
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
globalThis.describe("a change an event makes", () => {
|
|
119
|
+
globalThis.test("counts an After from when the event was recorded", () => {
|
|
120
|
+
let rows = {};
|
|
121
|
+
set(rows, "o1", {
|
|
122
|
+
TAG: "Once",
|
|
123
|
+
moment: {
|
|
124
|
+
TAG: "After",
|
|
125
|
+
_0: {
|
|
126
|
+
TAG: "Minutes",
|
|
127
|
+
_0: 10
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}, new Date("2026-10-16T09:00:00Z").getTime());
|
|
131
|
+
globalThis.expect(Stdlib_Option.flatMap(rows["o1"], AutomationSlice_Schedules$ReventlessCore.timeOf)).toEqual({
|
|
132
|
+
TAG: "Once",
|
|
133
|
+
moment: {
|
|
134
|
+
TAG: "At",
|
|
135
|
+
_0: {
|
|
136
|
+
year: 2026,
|
|
137
|
+
month: 10,
|
|
138
|
+
day: 16,
|
|
139
|
+
hour: 9,
|
|
140
|
+
minute: 10
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
globalThis.test("refuses a time that cannot fire, and changes nothing", () => {
|
|
146
|
+
let rows = {};
|
|
147
|
+
let refused = set(rows, "r1", {
|
|
148
|
+
TAG: "Calendar",
|
|
149
|
+
calendar: {
|
|
150
|
+
TAG: "Daily",
|
|
151
|
+
hour: 25,
|
|
152
|
+
minute: 0
|
|
153
|
+
}
|
|
154
|
+
}, undefined);
|
|
155
|
+
globalThis.expect([
|
|
156
|
+
Stdlib_Result.isError(refused),
|
|
157
|
+
liveKeys(rows)
|
|
158
|
+
]).toEqual([
|
|
159
|
+
true,
|
|
160
|
+
[]
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
globalThis.test("removes a once-only schedule when it fires, not a recurring one", () => {
|
|
164
|
+
let rows = {};
|
|
165
|
+
set(rows, "o1", {
|
|
166
|
+
TAG: "Once",
|
|
167
|
+
moment: {
|
|
168
|
+
TAG: "After",
|
|
169
|
+
_0: {
|
|
170
|
+
TAG: "Minutes",
|
|
171
|
+
_0: 10
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}, undefined);
|
|
175
|
+
set(rows, "r1", daily18, undefined);
|
|
176
|
+
AutomationSlice_Schedules$ReventlessCore.fired(rows, "o1");
|
|
177
|
+
AutomationSlice_Schedules$ReventlessCore.fired(rows, "r1");
|
|
178
|
+
globalThis.expect(liveKeys(rows)).toEqual(["r1"]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
globalThis.describe("the reconcile", () => {
|
|
183
|
+
globalThis.test("puts what the platform is missing and removes what the rows do not hold", async () => {
|
|
184
|
+
let rows = {};
|
|
185
|
+
set(rows, "r1", daily18, undefined);
|
|
186
|
+
set(rows, "r2", daily18, undefined);
|
|
187
|
+
let match = fakePlatform([
|
|
188
|
+
"r2",
|
|
189
|
+
"stray"
|
|
190
|
+
]);
|
|
191
|
+
await AutomationSlice_Schedules$ReventlessCore.reconcile(rows, match[0], undefined, now);
|
|
192
|
+
globalThis.expect(Object.keys(match[1]).toSorted(Primitive_string.compare)).toEqual([
|
|
193
|
+
"r1",
|
|
194
|
+
"r2"
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
globalThis.test("does not put again a schedule an event set and the platform holds", async () => {
|
|
198
|
+
let rows = {};
|
|
199
|
+
set(rows, "r1", daily18, undefined);
|
|
200
|
+
let match = fakePlatform(["r1"]);
|
|
201
|
+
await AutomationSlice_Schedules$ReventlessCore.reconcile(rows, match[0], undefined, now);
|
|
202
|
+
globalThis.expect(match[2]).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
globalThis.test("puts the declared time again, in case it changed", async () => {
|
|
205
|
+
let rows = {};
|
|
206
|
+
let match = fakePlatform(["default"]);
|
|
207
|
+
await AutomationSlice_Schedules$ReventlessCore.reconcile(rows, match[0], daily6, now);
|
|
208
|
+
globalThis.expect(match[2]).toEqual(["put default"]);
|
|
209
|
+
});
|
|
210
|
+
globalThis.test("drops a once-only time already past, from the rows and the platform", async () => {
|
|
211
|
+
let rows = {};
|
|
212
|
+
set(rows, "o1", {
|
|
213
|
+
TAG: "Once",
|
|
214
|
+
moment: {
|
|
215
|
+
TAG: "After",
|
|
216
|
+
_0: {
|
|
217
|
+
TAG: "Minutes",
|
|
218
|
+
_0: 10
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}, new Date("2026-10-16T09:00:00Z").getTime());
|
|
222
|
+
let match = fakePlatform(["o1"]);
|
|
223
|
+
await AutomationSlice_Schedules$ReventlessCore.reconcile(rows, match[0], undefined, now);
|
|
224
|
+
globalThis.expect([
|
|
225
|
+
liveKeys(rows),
|
|
226
|
+
Object.keys(match[1])
|
|
227
|
+
]).toEqual([
|
|
228
|
+
[],
|
|
229
|
+
[]
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
let Schedules;
|
|
235
|
+
|
|
236
|
+
export {
|
|
237
|
+
Schedules,
|
|
238
|
+
at,
|
|
239
|
+
daily6,
|
|
240
|
+
daily18,
|
|
241
|
+
now,
|
|
242
|
+
fakePlatform,
|
|
243
|
+
liveKeys,
|
|
244
|
+
set,
|
|
245
|
+
clear,
|
|
246
|
+
}
|
|
247
|
+
/* now 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
|
+
})
|