@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 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.293",
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-jest": "1.0.0-alpha.11",
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-interop": "3.0.0-alpha.39",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.162",
41
- "@reventlessdev/reventless-infra": "3.0.0-alpha.190"
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.99"
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
- (eventCollector->Pulumi.Output.flatMap(ec => ec->Component.operations), publishJsons)
194
- ->Pulumi.Output.all2
195
- ->Pulumi.Output.apply(((ecOps, publishJsonsFn)) => {
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
- processPending: async () => await Callback.phase2(publishJsonsFn),
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 () => await Callback.phase2(publishJsonsFn)
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>, ctx: Reventless.AutomationSlice.context) => {
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, ctx) => {
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
- rate: timeout->ScheduleOps.minutesFromNow,
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
- rate: ScheduleOps$ReventlessCore.minutesFromNow(directive._1),
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),
@@ -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: "Single",
39
- _0: m.getUTCFullYear(),
40
- _1: m.getUTCMonth() + 1 | 0,
41
- _2: m.getUTCDate(),
42
- _3: m.getUTCHours(),
43
- _4: m.getUTCMinutes()
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 schedule_rate = schedule.rate;
37
+ let schedule_time = schedule.time;
83
38
  let schedule_payload = schedule.payload;
84
39
  let schedule$1 = {
85
40
  name: name,
86
- rate: schedule_rate,
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
+ }