@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/package.json +8 -8
  3. package/src/admin/Platform_ComponentDefinitionsApi.res +3 -1
  4. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  5. package/src/components/AutomationSlice/AutomationSlice.res +2 -0
  6. package/src/components/AutomationSlice/AutomationSlice_Builder.res +133 -8
  7. package/src/components/AutomationSlice/AutomationSlice_Builder.res.mjs +89 -7
  8. package/src/components/AutomationSlice/AutomationSlice_Callback.res +136 -3
  9. package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +122 -4
  10. package/src/components/AutomationSlice/AutomationSlice_Route.res +3 -1
  11. package/src/components/AutomationSlice/AutomationSlice_Route.res.mjs +2 -1
  12. package/src/components/AutomationSlice/AutomationSlice_Schedules.res +149 -0
  13. package/src/components/AutomationSlice/AutomationSlice_Schedules.res.mjs +193 -0
  14. package/src/components/Dcb/Dcb_Builder.res +28 -4
  15. package/src/components/Dcb/Dcb_Builder.res.mjs +27 -5
  16. package/src/plugin/component/Plugin_Builder.res +10 -3
  17. package/src/plugin/component/Plugin_Builder.res.mjs +8 -2
  18. package/src/plugin/component/Plugin_Structure.res +7 -0
  19. package/src/plugin/component/Plugin_Structure.res.mjs +5 -2
  20. package/src/plugin/connect/PluginExtensionPoint_Plugin.res +1 -1
  21. package/src/plugin/connect/PluginExtensionPoint_Plugin.res.mjs +1 -1
  22. package/src/util/ScheduleOps.res +1 -55
  23. package/src/util/ScheduleOps.res.mjs +10 -57
  24. package/src/util/ScheduleTime.res +266 -0
  25. package/src/util/ScheduleTime.res.mjs +451 -0
  26. package/tests/admin/AdminApiSchemaDriftTest.res +1 -0
  27. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +4 -1
  28. package/tests/automationslice/AutomationSliceSchedulesTest.res +143 -0
  29. package/tests/automationslice/AutomationSliceSchedulesTest.res.mjs +247 -0
  30. package/tests/util/ScheduleTimeTest.res +256 -0
  31. package/tests/util/ScheduleTimeTest.res.mjs +532 -0
@@ -93,8 +93,35 @@ type runtime = {
93
93
  todoItems: Dict.t<todoRow>,
94
94
  phase1: phase1,
95
95
  phase2: phase2,
96
+ /** The slice's schedules, by key: what its schedules store holds. */
97
+ schedules: Dict.t<AutomationSlice_Schedules.row>,
98
+ /** What phase 1 changed in `schedules` since the last call, for the platform to
99
+ follow once the rows are stored. */
100
+ takeScheduleChanges: unit => array<AutomationSlice_Schedules.platformChange>,
101
+ /** Folds the given events' `reschedule` into empty rows, in order: a rebuild. */
102
+ rebuildSchedules: array<JSON.t> => unit,
96
103
  }
97
104
 
105
+ /** A tick as the runtime receives an event: an envelope from the `Clock`
106
+ source, recorded at the scheduled time. */
107
+ let tickEvent = (~key, ~at) =>
108
+ JSON.Encode.object(
109
+ Dict.fromArray([
110
+ ("id", key->JSON.Encode.string),
111
+ (
112
+ "meta",
113
+ {
114
+ ...Message.generateMeta(~service=Reventless.Clock.name),
115
+ time: at->Date.fromTime->Date.toISOString,
116
+ }->Reventless.Util_Sury.toJson(Reventless.Message.metaSchema),
117
+ ),
118
+ (
119
+ "event",
120
+ Reventless.Clock.Tick({key, at})->Reventless.Util_Sury.toJson(Reventless.Clock.eventSchema),
121
+ ),
122
+ ]),
123
+ )
124
+
98
125
  module type T = {
99
126
  module Spec: Reventless.AutomationSlice.Spec
100
127
  module Automation: Reventless.AutomationSlice.Automation with module Spec := Spec
@@ -108,6 +135,10 @@ module type T = {
108
135
 
109
136
  /** Phase 2: process all pending items, publishing commands via publishJsons. */
110
137
  let phase2: phase2
138
+
139
+ let schedules: Dict.t<AutomationSlice_Schedules.row>
140
+ let takeScheduleChanges: unit => array<AutomationSlice_Schedules.platformChange>
141
+ let rebuildSchedules: array<JSON.t> => unit
111
142
  }
112
143
 
113
144
  module Make = (
@@ -118,6 +149,20 @@ module Make = (
118
149
  module Automation = Automation
119
150
 
120
151
  let todoItems: Dict.t<todoRow> = Dict.make()
152
+ let schedules: Dict.t<AutomationSlice_Schedules.row> = Dict.make()
153
+ let scheduleChanges: ref<array<AutomationSlice_Schedules.platformChange>> = ref([])
154
+
155
+ let takeScheduleChanges = () => {
156
+ let changes = scheduleChanges.contents
157
+ scheduleChanges := []
158
+ changes
159
+ }
160
+
161
+ // Only a slice that maps the Clock can receive the ticks a schedule sends.
162
+ let readsClock =
163
+ Automation.mappings->Array.some((module(M: Automation.Mapping)) =>
164
+ M.sourceName == Reventless.Clock.name
165
+ )
121
166
 
122
167
  let now = () => Date.make()->Date.toISOString
123
168
 
@@ -133,6 +178,10 @@ module Make = (
133
178
  // the slice here makes the target's own events unmatchable, silently.
134
179
  let makeMeta = (): Reventless.Message.meta => Message.generateMeta(~service=Spec.targetName)
135
180
 
181
+ /** How long a row may stay `Processing` before its command is sent again. */
182
+ let processingTimeoutMs =
183
+ 15. *. 60. *. 1000.
184
+
136
185
  /** Record a failed attempt. The row is marked `Abandoned` at the moment the
137
186
  budget runs out rather than being left to fall out of the next pass's
138
187
  filter, so a reader never has to compare `retryCount` against a constant
@@ -168,6 +217,13 @@ module Make = (
168
217
 
169
218
  module Route = AutomationSlice_Route.Make(Spec, Automation)
170
219
 
220
+ // An event's recorded time, from its envelope's ISO `meta.time`.
221
+ let recordedAtOf = time =>
222
+ switch Date.fromString(time)->Date.getTime {
223
+ | millis if Float.isNaN(millis) => None
224
+ | millis => Some(millis)
225
+ }
226
+
171
227
  let apply = ({collected, resolved}: AutomationSlice_Route.routed<Spec.todoItem>) => {
172
228
  // Collect — append new items keyed by ID; first writer wins (idempotent).
173
229
  collected->Array.forEach(((id, item)) => {
@@ -194,9 +250,39 @@ module Make = (
194
250
  )
195
251
  }
196
252
 
197
- let phase1 = (events: array<JSON.t>, ctx: Reventless.AutomationSlice.context) => {
253
+ let reschedule = (
254
+ routed: array<AutomationSlice_Route.routed<Spec.todoItem>>,
255
+ ~setBy,
256
+ ~recordedAt,
257
+ ) =>
258
+ routed->Array.forEach(r =>
259
+ r.rescheduled->Array.forEach(change =>
260
+ if !readsClock {
261
+ EffectLogger.logError(
262
+ ~comp=`AutomationSlice(${Spec.name})`,
263
+ `a mapping changed a schedule, but no mapping reads the Clock, so no tick would reach the slice; the change is skipped`,
264
+ )->Effect.runSync
265
+ } else {
266
+ switch schedules->AutomationSlice_Schedules.apply(change, ~setBy, ~recordedAt) {
267
+ | Ok(platformChange) =>
268
+ scheduleChanges := scheduleChanges.contents->Array.concat([platformChange])
269
+ | Error(message) =>
270
+ EffectLogger.logError(
271
+ ~comp=`AutomationSlice(${Spec.name})`,
272
+ `${message}; the change is skipped`,
273
+ )->Effect.runSync
274
+ }
275
+ }
276
+ )
277
+ )
278
+
279
+ let phase1 = (events: array<JSON.t>, sliceCtx: Reventless.AutomationSlice.context) => {
198
280
  events->Array.forEach(json => {
199
281
  let context = json->Message.decode(Reventless.Message.contextSchema)
282
+ let ctx = switch recordedAtOf(context.meta.time) {
283
+ | Some(recordedAt) => {...sliceCtx, recordedAt}
284
+ | None => sliceCtx
285
+ }
200
286
  let sourceName = context.meta.service
201
287
  // Events arrive as `{id, meta, event}` envelopes (see Message.composeEventJson').
202
288
  // Per-mapping decoders work on the inner `event` payload (TAG + fields at
@@ -206,16 +292,63 @@ module Make = (
206
292
  | None => json
207
293
  }
208
294
  // Multiple mappings can share a sourceName; all matching dispatches run.
209
- Route.route(eventPayload, ~sourceName, ~sourceId=context.id, ctx)->Array.forEach(apply)
295
+ let routed = Route.route(eventPayload, ~sourceName, ~sourceId=context.id, ctx)
296
+ routed->Array.forEach(apply)
297
+ reschedule(
298
+ routed,
299
+ ~setBy=context.meta.msgId,
300
+ ~recordedAt=ctx.recordedAt->Option.getOr(Date.now()),
301
+ )
302
+
303
+ // A tick's envelope is addressed by its key.
304
+ if sourceName == Reventless.Clock.name {
305
+ schedules->AutomationSlice_Schedules.fired(~key=context.id)
306
+ }
210
307
  })
211
308
  }
212
309
 
310
+ let rebuildSchedules = (events: array<JSON.t>) => {
311
+ schedules->Dict.keysToArray->Array.forEach(key => schedules->Dict.delete(key))
312
+ events->Array.forEach(json => {
313
+ let context = json->Message.decode(Reventless.Message.contextSchema)
314
+ let eventPayload = switch json->JSON.Decode.object {
315
+ | Some(dict) => dict->Dict.get("event")->Option.getOr(json)
316
+ | None => json
317
+ }
318
+ let recordedAt = recordedAtOf(context.meta.time)->Option.getOr(Date.now())
319
+ Route.route(
320
+ eventPayload,
321
+ ~sourceName=context.meta.service,
322
+ ~sourceId=context.id,
323
+ {environment: "", platformName: "", pluginName: "", sliceName: Spec.name, recordedAt},
324
+ )->reschedule(~setBy=context.meta.msgId, ~recordedAt)
325
+ })
326
+ scheduleChanges := []
327
+ }
328
+
213
329
  let phase2 = async (publishJsons: ReventlessInfra.CommandTopic.publishJsons) => {
330
+ let currentTime = await Clock.currentTimeMillis->Effect.runPromise
214
331
  // A `Failed` row at or past the ceiling is one an older build left stranded,
215
332
  // or one whose Spec lowered `maxRetries` under it. Either way it is abandoned
216
333
  // in fact, so say so rather than letting it sit in a status that promises a
217
334
  // retry no pass will make.
218
335
  let newlyAbandoned: array<string> = []
336
+ // A command published but never answered leaves its row `Processing`. After
337
+ // the timeout that counts as a failed attempt, so this pass sends it again
338
+ // while the budget lasts.
339
+ todoItems
340
+ ->Dict.toArray
341
+ ->Array.forEach(((id, row)) =>
342
+ switch (row.status, row.processedAt->Option.map(t => Date.fromString(t)->Date.getTime)) {
343
+ | (Processing, Some(processedAt)) if currentTime -. processedAt >= processingTimeoutMs =>
344
+ let updated = row->afterFailedAttempt
345
+ todoItems->Dict.set(id, updated)
346
+ if updated.status == Abandoned {
347
+ newlyAbandoned->Array.push(id)
348
+ }
349
+ | _ => ()
350
+ }
351
+ )
219
352
  todoItems
220
353
  ->Dict.toArray
221
354
  ->Array.forEach(((id, row)) =>
@@ -247,7 +380,7 @@ module Make = (
247
380
  }
248
381
 
249
382
  item->Option.forEach(item => {
250
- switch Automation.process(id, item) {
383
+ switch Automation.process(id, item, ~now=currentTime) {
251
384
  | Some((targetId, command)) =>
252
385
  todoItems->Dict.set(id, {...row, status: Processing, processedAt: now()})
253
386
  let commandJson = try command
@@ -1,11 +1,14 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Sury from "sury";
4
+ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
4
5
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
6
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
7
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
8
+ import * as Clock from "effect/Clock";
7
9
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
10
  import * as Effect from "effect/Effect";
11
+ import * as Clock$Reventless from "@reventlessdev/reventless-spec/src/components/Clock.res.mjs";
9
12
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
10
13
  import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
11
14
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
@@ -13,6 +16,7 @@ import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/U
13
16
  import * as Message$ReventlessCore from "../../Message.res.mjs";
14
17
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
15
18
  import * as AutomationSlice_Route$ReventlessCore from "./AutomationSlice_Route.res.mjs";
19
+ import * as AutomationSlice_Schedules$ReventlessCore from "./AutomationSlice_Schedules.res.mjs";
16
20
 
17
21
  let todoStatusSchema = Sury.union([
18
22
  Sury.literal("Pending"),
@@ -44,10 +48,43 @@ function todoRowSchemaFor(itemSchema) {
44
48
  }));
45
49
  }
46
50
 
51
+ function tickEvent(key, at) {
52
+ let newrecord = {...Message$ReventlessCore.generateMeta(Clock$Reventless.name, undefined, undefined, undefined, undefined, undefined, undefined, undefined)};
53
+ return Object.fromEntries([
54
+ [
55
+ "id",
56
+ key
57
+ ],
58
+ [
59
+ "meta",
60
+ Util_Sury$Reventless.toJson((newrecord.time = new Date(at).toISOString(), newrecord), Message$Reventless.metaSchema)
61
+ ],
62
+ [
63
+ "event",
64
+ Util_Sury$Reventless.toJson({
65
+ TAG: "Tick",
66
+ key: key,
67
+ at: at
68
+ }, Clock$Reventless.eventSchema)
69
+ ]
70
+ ]);
71
+ }
72
+
47
73
  function Make(Spec) {
48
74
  return Automation => {
49
75
  let todoItems = {};
76
+ let schedules = {};
77
+ let scheduleChanges = {
78
+ contents: []
79
+ };
80
+ let takeScheduleChanges = () => {
81
+ let changes = scheduleChanges.contents;
82
+ scheduleChanges.contents = [];
83
+ return changes;
84
+ };
85
+ let readsClock = Automation.mappings.some(M => M.sourceName === Clock$Reventless.name);
50
86
  let makeMeta = () => Message$ReventlessCore.generateMeta(Spec.targetName, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
87
+ let processingTimeoutMs = 15 * 60 * 1000;
51
88
  let afterFailedAttempt = row => {
52
89
  let retryCount = row.retryCount + 1 | 0;
53
90
  let newrecord = {...row};
@@ -56,6 +93,14 @@ function Make(Spec) {
56
93
  return newrecord;
57
94
  };
58
95
  let Route = AutomationSlice_Route$ReventlessCore.Make(Spec)(Automation);
96
+ let recordedAtOf = time => {
97
+ let millis = new Date(time).getTime();
98
+ if (Number.isNaN(millis)) {
99
+ return;
100
+ } else {
101
+ return millis;
102
+ }
103
+ };
59
104
  let apply = param => {
60
105
  param.collected.forEach(param => {
61
106
  let id = param[0];
@@ -82,17 +127,86 @@ function Make(Spec) {
82
127
  todoItems[id] = newrecord;
83
128
  }));
84
129
  };
85
- let phase1 = (events, ctx) => {
130
+ let reschedule = (routed, setBy, recordedAt) => {
131
+ routed.forEach(r => {
132
+ r.rescheduled.forEach(change => {
133
+ if (!readsClock) {
134
+ return Effect.runSync(EffectLogger$ReventlessCore.logError(`AutomationSlice(` + Spec.name + `)`, undefined, `a mapping changed a schedule, but no mapping reads the Clock, so no tick would reach the slice; the change is skipped`));
135
+ }
136
+ let platformChange = AutomationSlice_Schedules$ReventlessCore.apply(schedules, change, setBy, recordedAt);
137
+ if (platformChange.TAG === "Ok") {
138
+ scheduleChanges.contents = scheduleChanges.contents.concat([platformChange._0]);
139
+ return;
140
+ } else {
141
+ return Effect.runSync(EffectLogger$ReventlessCore.logError(`AutomationSlice(` + Spec.name + `)`, undefined, platformChange._0 + `; the change is skipped`));
142
+ }
143
+ });
144
+ });
145
+ };
146
+ let phase1 = (events, sliceCtx) => {
86
147
  events.forEach(json => {
87
148
  let context = Message$ReventlessCore.decode(json, Message$Reventless.contextSchema);
149
+ let recordedAt = recordedAtOf(context.meta.time);
150
+ let ctx;
151
+ if (recordedAt !== undefined) {
152
+ let newrecord = {...sliceCtx};
153
+ newrecord.recordedAt = recordedAt;
154
+ ctx = newrecord;
155
+ } else {
156
+ ctx = sliceCtx;
157
+ }
88
158
  let sourceName = context.meta.service;
89
159
  let dict = Stdlib_JSON.Decode.object(json);
90
160
  let eventPayload = dict !== undefined ? Stdlib_Option.getOr(dict["event"], json) : json;
91
- Route.route(eventPayload, sourceName, context.id, ctx).forEach(apply);
161
+ let routed = Route.route(eventPayload, sourceName, context.id, ctx);
162
+ routed.forEach(apply);
163
+ reschedule(routed, context.meta.msgId, Stdlib_Option.getOr(ctx.recordedAt, Date.now()));
164
+ if (sourceName === Clock$Reventless.name) {
165
+ return AutomationSlice_Schedules$ReventlessCore.fired(schedules, context.id);
166
+ }
167
+ });
168
+ };
169
+ let rebuildSchedules = events => {
170
+ Object.keys(schedules).forEach(key => Stdlib_Dict.$$delete(schedules, key));
171
+ events.forEach(json => {
172
+ let context = Message$ReventlessCore.decode(json, Message$Reventless.contextSchema);
173
+ let dict = Stdlib_JSON.Decode.object(json);
174
+ let eventPayload = dict !== undefined ? Stdlib_Option.getOr(dict["event"], json) : json;
175
+ let recordedAt = Stdlib_Option.getOr(recordedAtOf(context.meta.time), Date.now());
176
+ reschedule(Route.route(eventPayload, context.meta.service, context.id, {
177
+ environment: "",
178
+ platformName: "",
179
+ pluginName: "",
180
+ sliceName: Spec.name,
181
+ recordedAt: recordedAt
182
+ }), context.meta.msgId, recordedAt);
92
183
  });
184
+ scheduleChanges.contents = [];
93
185
  };
94
186
  let phase2 = async publishJsons => {
187
+ let currentTime = await Effect.runPromise(Clock.currentTimeMillis);
95
188
  let newlyAbandoned = [];
189
+ Object.entries(todoItems).forEach(param => {
190
+ let row = param[1];
191
+ let id = param[0];
192
+ let match = row.status;
193
+ let match$1 = Stdlib_Option.map(row.processedAt, t => new Date(t).getTime());
194
+ if (match !== "Processing") {
195
+ return;
196
+ }
197
+ if (match$1 === undefined) {
198
+ return;
199
+ }
200
+ if (currentTime - match$1 < processingTimeoutMs) {
201
+ return;
202
+ }
203
+ let updated = afterFailedAttempt(row);
204
+ todoItems[id] = updated;
205
+ if (updated.status === "Abandoned") {
206
+ newlyAbandoned.push(id);
207
+ return;
208
+ }
209
+ });
96
210
  Object.entries(todoItems).forEach(param => {
97
211
  let row = param[1];
98
212
  if (!(row.status === "Failed" && row.retryCount >= Spec.maxRetries)) {
@@ -128,7 +242,7 @@ function Make(Spec) {
128
242
  item = undefined;
129
243
  }
130
244
  Stdlib_Option.forEach(item, item => {
131
- let match = Automation.process(id, item);
245
+ let match = Automation.process(id, item, currentTime);
132
246
  if (match === undefined) {
133
247
  return;
134
248
  }
@@ -225,7 +339,10 @@ function Make(Spec) {
225
339
  Spec: Spec,
226
340
  todoItems: todoItems,
227
341
  phase1: phase1,
228
- phase2: phase2
342
+ phase2: phase2,
343
+ schedules: schedules,
344
+ takeScheduleChanges: takeScheduleChanges,
345
+ rebuildSchedules: rebuildSchedules
229
346
  };
230
347
  };
231
348
  }
@@ -234,6 +351,7 @@ export {
234
351
  todoStatusSchema,
235
352
  todoRowSchema,
236
353
  todoRowSchemaFor,
354
+ tickEvent,
237
355
  Make,
238
356
  }
239
357
  /* todoStatusSchema Not a pure module */
@@ -1,4 +1,4 @@
1
- // Decode one source event and run its mappings' `collect`/`resolve`, with no
1
+ // Decode one source event and run its mappings' `collect`/`resolve`/`reschedule`, with no
2
2
  // side effects. Shared by the runtime callback, which applies the result to its
3
3
  // to-do list, and by the GWT sweep, which must route exactly as the runtime does.
4
4
 
@@ -6,6 +6,7 @@
6
6
  type routed<'item> = {
7
7
  collected: array<(string, 'item)>,
8
8
  resolved: option<string>,
9
+ rescheduled: array<Reventless.AutomationSlice.scheduleChange>,
9
10
  }
10
11
 
11
12
  module Make = (
@@ -31,6 +32,7 @@ module Make = (
31
32
  decoder.decode(~eventType, ~data=dataDict)->Option.map(event => {
32
33
  collected: M.collect(event, ~sourceId, ctx),
33
34
  resolved: M.resolve(event),
35
+ rescheduled: M.reschedule(event),
34
36
  })
35
37
  }
36
38
  {sourceName: M.sourceName, handle}
@@ -13,7 +13,8 @@ function Make(Spec) {
13
13
  let match = Message$ReventlessCore.splitMessage(json);
14
14
  return Stdlib_Option.map(decoder.decode(match[0], match[1]), event => ({
15
15
  collected: M.collect(event, sourceId, ctx),
16
- resolved: M.resolve(event)
16
+ resolved: M.resolve(event),
17
+ rescheduled: M.reschedule(event)
17
18
  }));
18
19
  };
19
20
  return {
@@ -0,0 +1,149 @@
1
+ // The schedules of one automation slice: a row per key, kept in the slice's schedules
2
+ // store. The rows are the truth and the platform's schedules follow them. Shared by
3
+ // the runtime and the GWT DSL, so a scenario folds `reschedule` as the runtime does.
4
+
5
+ /** The `setBy` of the row a spec's declared `schedule` writes. */
6
+ let declared = "declared"
7
+
8
+ @schema
9
+ type row = {
10
+ key: string,
11
+ /** The time as the JSON text of `Reventless.Schedule.timeSchema`, an `After` set by an event
12
+ already counted from when the event was recorded. Absent when an event cleared
13
+ the slice-wide key, so a deploy does not put the declared time back. */
14
+ time?: string,
15
+ /** The id of the event that set or cleared it, or `declared`. */
16
+ setBy: string,
17
+ setAt: string,
18
+ }
19
+
20
+ /** What the platform is told after a row changed. */
21
+ type platformChange =
22
+ | Put({key: string, time: Reventless.Schedule.time})
23
+ | Remove({key: string})
24
+
25
+ let encodeTime = (time: Reventless.Schedule.time) =>
26
+ time->Reventless.Util_Sury.toJson(Reventless.Schedule.timeSchema)->JSON.stringify
27
+ let decodeTime = (text: string): Reventless.Schedule.time =>
28
+ text->JSON.parseOrThrow->Reventless.Util_Sury.fromJson(Reventless.Schedule.timeSchema)
29
+ let timeOf = (row: row) => row.time->Option.map(decodeTime)
30
+
31
+ let iso = millis => millis->Date.fromTime->Date.toISOString
32
+
33
+ // The slice-wide key keeps a row saying it was cleared; any other key's row goes.
34
+ let clearRow = (rows: dict<row>, ~key, ~setBy, ~setAt) =>
35
+ if key == Reventless.Clock.defaultKey {
36
+ rows->Dict.set(key, {key, setBy, setAt})
37
+ } else {
38
+ rows->Dict.delete(key)
39
+ }
40
+
41
+ /**
42
+ Applies one change an event made, recorded at `recordedAt`, and returns what the
43
+ platform is told. A time that cannot fire is refused with every problem it has, and
44
+ changes nothing.
45
+ */
46
+ let apply = (
47
+ rows: dict<row>,
48
+ change: Reventless.AutomationSlice.scheduleChange,
49
+ ~setBy,
50
+ ~recordedAt,
51
+ ): result<platformChange, string> =>
52
+ switch change {
53
+ | Set({key, time: wanted}) =>
54
+ switch ScheduleTime.errors(wanted) {
55
+ | [] =>
56
+ let resolved = wanted->ScheduleTime.resolveOnce(~from=recordedAt)
57
+ rows->Dict.set(key, {key, time: encodeTime(resolved), setBy, setAt: iso(recordedAt)})
58
+ Ok(Put({key, time: resolved}))
59
+ | errs => Error(`schedule ${key}: ${errs->Array.join("; ")}`)
60
+ }
61
+ | Clear({key: cleared}) =>
62
+ clearRow(rows, ~key=cleared, ~setBy, ~setAt=iso(recordedAt))
63
+ Ok(Remove({key: cleared}))
64
+ }
65
+
66
+ /** A tick of `key` arrived: a once-only schedule has fired, so its row goes. */
67
+ let fired = (rows: dict<row>, ~key) =>
68
+ rows
69
+ ->Dict.get(key)
70
+ ->Option.forEach(row =>
71
+ switch row->timeOf {
72
+ | Some(Once(_)) => clearRow(rows, ~key, ~setBy=row.setBy, ~setAt=row.setAt)
73
+ | _ => ()
74
+ }
75
+ )
76
+
77
+ /**
78
+ Brings the spec's declared time into the rows under `Reventless.Clock.defaultKey`: written when
79
+ no event has set or cleared that key, replaced when the declaration changed, and
80
+ removed when the spec no longer declares one.
81
+ */
82
+ let declare = (rows: dict<row>, declaredTime: option<Reventless.Schedule.time>, ~now) => {
83
+ let key = Reventless.Clock.defaultKey
84
+ let write = time =>
85
+ rows->Dict.set(key, {key, time: encodeTime(time), setBy: declared, setAt: iso(now)})
86
+ switch (declaredTime, rows->Dict.get(key)) {
87
+ | (Some(time), None) => write(time)
88
+ | (Some(time), Some(row)) if row.setBy == declared && row.time != Some(encodeTime(time)) =>
89
+ write(time)
90
+ | (None, Some(row)) if row.setBy == declared => rows->Dict.delete(key)
91
+ | _ => ()
92
+ }
93
+ }
94
+
95
+ // A once-only time already past fires no more.
96
+ let isPast = (time: Reventless.Schedule.time, ~now) =>
97
+ switch time {
98
+ | Once(_) => time->ScheduleTime.nextFiring(~after=now)->Option.isNone
99
+ | _ => false
100
+ }
101
+
102
+ /**
103
+ Brings the platform in line with the rows, after the declared time is brought in: a
104
+ schedule missing from the platform is put, the declared one is put again in case its
105
+ time changed, and one the rows do not hold is removed. A once-only time already past
106
+ is not put again, and its row goes. A row an event changed reached the platform when
107
+ it changed, so it is not put again.
108
+ */
109
+ let reconcile = async (
110
+ rows: dict<row>,
111
+ platform: ReventlessInfra.AutomationSlice.platformSchedules,
112
+ ~declaredTime,
113
+ ~now,
114
+ ) => {
115
+ declare(rows, declaredTime, ~now)
116
+ let live =
117
+ rows
118
+ ->Dict.valuesToArray
119
+ ->Array.filterMap(row =>
120
+ switch row->timeOf {
121
+ | Some(time) if isPast(time, ~now) =>
122
+ clearRow(rows, ~key=row.key, ~setBy=row.setBy, ~setAt=row.setAt)
123
+ None
124
+ | Some(time) => Some((row, time))
125
+ | None => None
126
+ }
127
+ )
128
+ let held = await platform.names()
129
+ for i in 0 to live->Array.length - 1 {
130
+ let (row, time) = live->Array.getUnsafe(i)
131
+ if row.setBy == declared || !(held->Array.includes(platform.nameOf(row.key))) {
132
+ await platform.put(~key=row.key, time)
133
+ }
134
+ }
135
+ let wanted = live->Array.map(((row, _)) => platform.nameOf(row.key))
136
+ let extra = held->Array.filter(name => !(wanted->Array.includes(name)))
137
+ for i in 0 to extra->Array.length - 1 {
138
+ await platform.removeName(extra->Array.getUnsafe(i))
139
+ }
140
+ }
141
+
142
+ /** Tells the platform what the rows' changes were, in order. */
143
+ let tell = async (platform: ReventlessInfra.AutomationSlice.platformSchedules, changes) =>
144
+ for i in 0 to changes->Array.length - 1 {
145
+ switch changes->Array.getUnsafe(i) {
146
+ | Put({key, time}) => await platform.put(~key, time)
147
+ | Remove({key}) => await platform.removeName(platform.nameOf(key))
148
+ }
149
+ }