@reventlessdev/reventless-core 3.0.0-alpha.295 → 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 CHANGED
@@ -3,6 +3,26 @@
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.296 (2026-10-16)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** a core test names the schedules module directly ([ac459e5](https://github.com/ReventlessDev/reventless-core/commit/ac459e5ece6479086b86f86019ed1176b4471c9b))
11
+ * feat!(automation): schedules set by events ([53bc436](https://github.com/ReventlessDev/reventless-core/commit/53bc4363dffce81ef36475728bf0dae704716021))
12
+ * feat!(automation): declared schedules tick an automation ([420acd2](https://github.com/ReventlessDev/reventless-core/commit/420acd2233aadf700141a31d9d3e0fdc8074bef7))
13
+ ### Features
14
+
15
+ * **examples:** a marketing digest at the time each customer chooses ([3521b10](https://github.com/ReventlessDev/reventless-core/commit/3521b10c4f3ff64f3e4561e94e051892cdfe5d5f))
16
+
17
+ ### BREAKING CHANGES
18
+
19
+ * a hand-written AutomationSlice.Mapping.Make the PPX does
20
+ not see must define `let reschedule = _ => []`.
21
+ * a hand-written AutomationSlice spec the PPX does not see must
22
+ declare `let schedule = None`.
23
+
24
+
25
+
6
26
  # 3.0.0-alpha.295 (2026-10-15)
7
27
 
8
28
  * feat!: an automation's process takes ~now, and collect the event's recorded time ([8c8f3f5](https://github.com/ReventlessDev/reventless-core/commit/8c8f3f570474b1658b605b726077debc7d6a1dc7))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.295",
3
+ "version": "3.0.0-alpha.296",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,21 +29,21 @@
29
29
  "sury": "11.0.0",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "1.0.0-alpha.0",
32
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
33
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.22",
34
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.12",
34
+ "@reventlessdev/rescript-node": "2.0.0-alpha.22",
35
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
35
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.23",
36
37
  "@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-infra": "3.0.0-alpha.192",
40
39
  "@reventlessdev/reventless-interop": "3.0.0-alpha.40",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.164"
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.165",
41
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.193"
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.100"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.101"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "^12.3.1"
@@ -30,7 +30,7 @@ let sdlTypes: array<string> = [
30
30
  // def may decline to state them, and a state whose key cannot be resolved has no
31
31
  // `idField` to report.
32
32
  `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n namedWhenRetired: Boolean!\n}`,
33
- `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
33
+ `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n schedule: String\n}`,
34
34
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n consumedSources: [String!]\n}`,
35
35
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
36
36
  // The subscriber's half of the port's translation table — see `handledEventDef`.
@@ -170,6 +170,8 @@ let encodeAutomationSliceDef = (a: automationSliceDef): JSON.t =>
170
170
  // from the structure rather than from the deploy-time inspector sync.
171
171
  ("targetName", JSON.Encode.string(a.targetName)),
172
172
  ("chapter", a.chapter->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
173
+ // The declared time, as its JSON text; null for an automation started by events only.
174
+ ("schedule", a.schedule->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
173
175
  ])->JSON.Encode.object
174
176
 
175
177
  let encodeOutboundTranslationSliceDef = (o: outboundTranslationSliceDef): JSON.t =>
@@ -11,7 +11,7 @@ let sdlTypes = [
11
11
  `type Platform_ErrorDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
12
12
  `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n errors: [Platform_ErrorDef!]!\n chapter: String\n}`,
13
13
  `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n namedWhenRetired: Boolean!\n}`,
14
- `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
14
+ `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n schedule: String\n}`,
15
15
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n consumedSources: [String!]\n}`,
16
16
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
17
17
  `type Platform_HandledEventDef {\n name: String!\n toCommandTypes: [String!]!\n}`,
@@ -278,6 +278,10 @@ function encodeAutomationSliceDef(a) {
278
278
  [
279
279
  "chapter",
280
280
  Stdlib_Option.mapOr(a.chapter, null, prim => prim)
281
+ ],
282
+ [
283
+ "schedule",
284
+ Stdlib_Option.mapOr(a.schedule, null, prim => prim)
281
285
  ]
282
286
  ]);
283
287
  }
@@ -26,6 +26,8 @@ module type T = {
26
26
  module Automation: Reventless.AutomationSlice.Automation with module Spec := Spec
27
27
  type component = Component.t<t, outputs, operations>
28
28
  let queryDbName: string
29
+ /** The schedules store's name, for a slice that maps the `Clock`. */
30
+ let schedulesDbName: option<string>
29
31
  /** Names of all sources this slice consumes (deduplicated). Used by
30
32
  `Plugin_Builder` for the source-name fail-fast assembly check. */
31
33
  let sourceNames: array<string>
@@ -22,6 +22,10 @@ module Make = (
22
22
  let api: unit => QueryDbStorage.api
23
23
  let apiRole: unit => QueryDbStorage.role
24
24
  },
25
+ // The platform's schedules of a slice, for the runtime this builder's operations run on.
26
+ Schedules: {
27
+ let platformFor: (~slice: string) => ReventlessInfra.AutomationSlice.platformSchedules
28
+ },
25
29
  ) => {
26
30
  let finish = EventCollectorRuntimeBuilder.finish
27
31
  module Make = (
@@ -39,12 +43,16 @@ module Make = (
39
43
  // Names of all sources this slice subscribes to (deduplicated). Multiple
40
44
  // mappings can share a source name (e.g., two mappings reading the same
41
45
  // Aggregate's events for different reasons).
42
- let sourceNames =
46
+ let mappedSources =
43
47
  Automation.mappings
44
48
  ->Array.map((module(M: Automation.Mapping)) => M.sourceName)
45
49
  ->Belt.Set.String.fromArray
46
50
  ->Belt.Set.String.toArray
47
51
 
52
+ // The event-topic sources; `Clock` is delivered by the slice's schedules, not a topic.
53
+ let sourceNames = mappedSources->Array.filter(name => name != Reventless.Clock.name)
54
+ let readsClock = mappedSources->Array.includes(Reventless.Clock.name)
55
+
48
56
  // QueryDb for TODO list — stores todoRow keyed by string ID
49
57
  module TodoQueryDbSpec = {
50
58
  module Id = Reventless.Id.String
@@ -60,6 +68,55 @@ module Make = (
60
68
  }
61
69
 
62
70
  module SpecificQueryDb = QueryDb_Builder.Make(TodoQueryDbSpec, QueryDbStorage, QueryDbResolvers)
71
+
72
+ // The schedules store: a row per key, made only for a slice that maps the Clock.
73
+ let schedulesStoreName = Spec.name ++ "Schedule"
74
+ let schedulesDbName = readsClock ? Some(schedulesStoreName) : None
75
+ module SchedulesQueryDbSpec = {
76
+ module Id = Reventless.Id.String
77
+ let name = schedulesStoreName
78
+ let moduleUrl: string = %raw(`import.meta.url`)
79
+ type state = AutomationSlice_Schedules.row
80
+ let stateSchema = AutomationSlice_Schedules.rowSchema
81
+ let config = Reventless.ReadModel.config()
82
+ let subIdConfig: option<Reventless.ReadModel.subIdConfig<state>> = None
83
+ type role = Reventless.Role.name
84
+ let authorization: Reventless.Authorization.rule<role> = AllowAuthenticated
85
+ let visibility: Reventless.Visibility.t = Public
86
+ }
87
+ module SchedulesQueryDb = QueryDb_Builder.Make(
88
+ SchedulesQueryDbSpec,
89
+ QueryDbStorage,
90
+ QueryDbResolvers,
91
+ )
92
+
93
+ // Written whole, and a key the rows no longer hold is deleted.
94
+ let syncSchedules = async (
95
+ ops: option<SchedulesQueryDb.operations>,
96
+ ~stored: ref<array<string>>,
97
+ ) =>
98
+ switch ops {
99
+ | Some(ops) =>
100
+ let keys = Callback.schedules->Dict.keysToArray
101
+ for i in 0 to keys->Array.length - 1 {
102
+ let key = keys->Array.getUnsafe(i)
103
+ let _ = await ops.save(
104
+ key->Reventless.Id.String.makeFromString,
105
+ Callback.schedules->Dict.getUnsafe(key),
106
+ QueryDb.Overwrite,
107
+ None,
108
+ )
109
+ }
110
+ let gone = stored.contents->Array.filter(key => !(keys->Array.includes(key)))
111
+ for i in 0 to gone->Array.length - 1 {
112
+ let _ = await ops.delete(
113
+ gone->Array.getUnsafe(i)->Reventless.Id.String.makeFromString,
114
+ None,
115
+ )
116
+ }
117
+ stored := keys
118
+ | None => ()
119
+ }
63
120
  module SpecificEventCollector = EventCollector_Builder.Make(
64
121
  RuntimeEnvironment,
65
122
  EventCollectorChannel,
@@ -116,6 +173,32 @@ module Make = (
116
173
  }
117
174
  )
118
175
 
176
+ // A `Clock` mapping receives ticks only from a declared schedule, or from one an
177
+ // event sets through another mapping's `reschedule`.
178
+ if readsClock && Spec.schedule->Option.isNone && sourceNames->Array.length == 0 {
179
+ JsError.throwWithMessage(
180
+ `AutomationSlice "${Spec.name}" maps the Clock source but declares no schedule ` ++
181
+ `and reads no event that could set one, so no tick will ever reach it. ` ++ `Declare \`let schedule\` on its spec.`,
182
+ )
183
+ }
184
+
185
+ let schedulesDb = readsClock
186
+ ? Some(
187
+ SchedulesQueryDb.make(
188
+ ~api=Api.api(),
189
+ ~apiRole=Api.apiRole(),
190
+ ~owner={kind: ComponentType.AutomationSlice, name: Spec.name},
191
+ ~opts,
192
+ ),
193
+ )
194
+ : None
195
+ let schedulesOps: Pulumi.Output.t<option<SchedulesQueryDb.operations>> = switch schedulesDb {
196
+ | Some(db) => db->Component.operations->Pulumi.Output.apply(ops => Some(ops))
197
+ | None => Pulumi.Output.make(None)
198
+ }
199
+ let platform = Schedules.platformFor(~slice=Spec.name)
200
+ let storedSchedules = ref([])
201
+
119
202
  // Filter to only the topics this slice consumes from.
120
203
  let sourceSet = sourceNames->Belt.Set.String.fromArray
121
204
  let eventTopics = allEventTopics->EventTopic.filter(sourceSet)
@@ -127,9 +210,9 @@ module Make = (
127
210
  // first event arrival (which silently dropped Phase 2 in the previous
128
211
  // shape).
129
212
  let eventCollector =
130
- (queryDb->Component.operations, publishJsons)
131
- ->Pulumi.Output.all2
132
- ->Pulumi.Output.apply(((queryDbOps, publishJsonsFn)) => {
213
+ (queryDb->Component.operations, publishJsons, schedulesOps)
214
+ ->Pulumi.Output.all3
215
+ ->Pulumi.Output.apply(((queryDbOps, publishJsonsFn, schedulesOps)) => {
133
216
  let ec = SpecificEventCollector.make(
134
217
  ~name=Spec.name,
135
218
  ~eventTopics,
@@ -148,6 +231,13 @@ module Make = (
148
231
  // Sync the post-phase-1 TODO state so consumers awaiting the
149
232
  // originating publishEvent observe Pending rows immediately.
150
233
  await syncToQueryDb(queryDbOps)
234
+ // The schedule rows are stored first, then the platform follows them.
235
+ switch Callback.takeScheduleChanges() {
236
+ | [] => ()
237
+ | changes =>
238
+ await syncSchedules(schedulesOps, ~stored=storedSchedules)
239
+ await AutomationSlice_Schedules.tell(platform, changes)
240
+ }
151
241
  // Phase 2 must NOT be awaited here. Phase 2 publishes commands
152
242
  // whose downstream events get fanned out by the bus to all
153
243
  // subscribers of this same topic — including this very fiber.
@@ -194,15 +284,41 @@ module Make = (
194
284
  eventCollector->Pulumi.Output.flatMap(ec => ec->Component.operations),
195
285
  publishJsons,
196
286
  queryDb->Component.operations,
287
+ schedulesOps,
197
288
  )
198
- ->Pulumi.Output.all3
199
- ->Pulumi.Output.apply(((ecOps, publishJsonsFn, queryDbOps)) => {
289
+ ->Pulumi.Output.all4
290
+ ->Pulumi.Output.apply(((ecOps, publishJsonsFn, queryDbOps, schedulesOps)) => {
291
+ let processPending = async () => {
292
+ await Callback.phase2(publishJsonsFn)
293
+ await syncToQueryDb(queryDbOps)
294
+ }
295
+ let reconcileSchedules = async () =>
296
+ if readsClock {
297
+ await AutomationSlice_Schedules.reconcile(
298
+ Callback.schedules,
299
+ platform,
300
+ ~declaredTime=Spec.schedule,
301
+ ~now=Date.now(),
302
+ )
303
+ await syncSchedules(schedulesOps, ~stored=storedSchedules)
304
+ }
200
305
  let ops: AutomationSlice.operations = {
201
306
  enqueueEvent: ecOps.enqueueEvent,
202
307
  // As the AWS sweep does: the rows phase 2 moved are written back.
203
- processPending: async () => {
204
- await Callback.phase2(publishJsonsFn)
308
+ processPending,
309
+ tick: async (~key, ~at) => {
310
+ Callback.phase1([AutomationSlice_Callback.tickEvent(~key, ~at)], context)
205
311
  await syncToQueryDb(queryDbOps)
312
+ // A once-only schedule's row went when it fired, and a Clock mapping
313
+ // may change schedules too.
314
+ await syncSchedules(schedulesOps, ~stored=storedSchedules)
315
+ await AutomationSlice_Schedules.tell(platform, Callback.takeScheduleChanges())
316
+ await processPending()
317
+ },
318
+ reconcileSchedules,
319
+ rebuildSchedules: async events => {
320
+ Callback.rebuildSchedules(events)
321
+ await reconcileSchedules()
206
322
  },
207
323
  }
208
324
  ops
@@ -217,6 +333,7 @@ module Make = (
217
333
  let outputs: AutomationSlice.outputs = {
218
334
  resources: aggregatedResources,
219
335
  queryDb: queryDb->Component.outputs,
336
+ schedulesDb: ?(schedulesDb->Option.map(db => db->Component.outputs)),
220
337
  }
221
338
  self->Component.setOutputs(outputs)
222
339
  }
@@ -11,6 +11,8 @@ import * as Pulumi from "@pulumi/pulumi";
11
11
  import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
12
12
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
13
13
  import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
14
+ import * as Clock$Reventless from "@reventlessdev/reventless-spec/src/components/Clock.res.mjs";
15
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
14
16
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
15
17
  import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
16
18
  import * as Component$ReventlessCore from "../Component.res.mjs";
@@ -22,13 +24,16 @@ import * as AutomationSlice$ReventlessCore from "./AutomationSlice.res.mjs";
22
24
  import * as QueryDb_Builder$ReventlessCore from "../QueryDb/QueryDb_Builder.res.mjs";
23
25
  import * as EventCollector_Builder$ReventlessCore from "../EventCollector/EventCollector_Builder.res.mjs";
24
26
  import * as AutomationSlice_Callback$ReventlessCore from "./AutomationSlice_Callback.res.mjs";
27
+ import * as AutomationSlice_Schedules$ReventlessCore from "./AutomationSlice_Schedules.res.mjs";
25
28
 
26
29
  function Make(RuntimeEnvironment) {
27
- return QueryDbStorage => (QueryDbResolvers => (EventCollectorChannel => (EventCollectorRuntimeBuilder => (Api => {
30
+ return QueryDbStorage => (QueryDbResolvers => (EventCollectorChannel => (EventCollectorRuntimeBuilder => (Api => (Schedules => {
28
31
  let Make = Spec => (Automation => {
29
32
  let Callback = AutomationSlice_Callback$ReventlessCore.Make(Spec)(Automation);
30
33
  let queryDbName = Spec.name + "Todo";
31
- let sourceNames = Belt_SetString.toArray(Belt_SetString.fromArray(Automation.mappings.map(M => M.sourceName)));
34
+ let mappedSources = Belt_SetString.toArray(Belt_SetString.fromArray(Automation.mappings.map(M => M.sourceName)));
35
+ let sourceNames = mappedSources.filter(name => name !== Clock$Reventless.name);
36
+ let readsClock = mappedSources.includes(Clock$Reventless.name);
32
37
  let moduleUrl = import.meta.url;
33
38
  let config = ReadModel$Reventless.config(undefined, undefined, undefined);
34
39
  let SpecificQueryDb = QueryDb_Builder$ReventlessCore.Make({
@@ -41,6 +46,35 @@ function Make(RuntimeEnvironment) {
41
46
  authorization: "AllowAuthenticated",
42
47
  visibility: "Public"
43
48
  })(QueryDbStorage)(QueryDbResolvers);
49
+ let schedulesStoreName = Spec.name + "Schedule";
50
+ let schedulesDbName = readsClock ? schedulesStoreName : undefined;
51
+ let moduleUrl$1 = import.meta.url;
52
+ let config$1 = ReadModel$Reventless.config(undefined, undefined, undefined);
53
+ let SchedulesQueryDb = QueryDb_Builder$ReventlessCore.Make({
54
+ Id: Id$Reventless.$$String,
55
+ name: schedulesStoreName,
56
+ moduleUrl: moduleUrl$1,
57
+ stateSchema: AutomationSlice_Schedules$ReventlessCore.rowSchema,
58
+ config: config$1,
59
+ subIdConfig: undefined,
60
+ authorization: "AllowAuthenticated",
61
+ visibility: "Public"
62
+ })(QueryDbStorage)(QueryDbResolvers);
63
+ let syncSchedules = async (ops, stored) => {
64
+ if (ops === undefined) {
65
+ return;
66
+ }
67
+ let keys = Object.keys(Callback.schedules);
68
+ for (let i = 0, i_finish = keys.length; i < i_finish; ++i) {
69
+ let key = keys[i];
70
+ await ops.save(Id$Reventless.$$String.makeFromString(key), Callback.schedules[key], "Overwrite", undefined);
71
+ }
72
+ let gone = stored.contents.filter(key => !keys.includes(key));
73
+ for (let i$1 = 0, i_finish$1 = gone.length; i$1 < i_finish$1; ++i$1) {
74
+ await ops.delete(Id$Reventless.$$String.makeFromString(gone[i$1]), undefined);
75
+ }
76
+ stored.contents = keys;
77
+ };
44
78
  let SpecificEventCollector = EventCollector_Builder$ReventlessCore.Make(RuntimeEnvironment)(EventCollectorChannel);
45
79
  let syncToQueryDb = async queryDbOps => {
46
80
  let items = Object.entries(Callback.todoItems);
@@ -67,12 +101,26 @@ function Make(RuntimeEnvironment) {
67
101
  let availableNames = Object.keys(allEventTopics).toSorted(Primitive_string.compare).join(", ");
68
102
  Stdlib_JsError.throwWithMessage(`AutomationSlice "` + Spec.name + `" has a Mapping with sourceName "` + sourceName + `", but no EventTopic with that key exists in allEventTopics. ` + (`Available source names: [` + availableNames + `]. `) + `Check Mapping.Make's first arg matches an Aggregate Spec.name or a DCB source name (typically "<pluginName>DcbEventLog").`);
69
103
  });
104
+ if (readsClock && Stdlib_Option.isNone(Spec.schedule) && sourceNames.length === 0) {
105
+ Stdlib_JsError.throwWithMessage(`AutomationSlice "` + Spec.name + `" maps the Clock source but declares no schedule and reads no event that could set one, so no tick will ever reach it. Declare \`let schedule\` on its spec.`);
106
+ }
107
+ let schedulesDb = readsClock ? Primitive_option.some(SchedulesQueryDb.make(Api.api(), Api.apiRole(), undefined, {
108
+ kind: "AutomationSlice",
109
+ name: Spec.name
110
+ }, opts)) : undefined;
111
+ let schedulesOps = schedulesDb !== undefined ? Component$ReventlessCore.operations(Primitive_option.valFromOption(schedulesDb)).apply(ops => ops) : Pulumi.output(undefined);
112
+ let platform = Schedules.platformFor(Spec.name);
113
+ let storedSchedules = {
114
+ contents: []
115
+ };
70
116
  let sourceSet = Belt_SetString.fromArray(sourceNames);
71
117
  let eventTopics = EventTopic$ReventlessCore.filter(allEventTopics, sourceSet);
72
118
  let eventCollector = Pulumi.all([
73
119
  Component$ReventlessCore.operations(queryDb),
74
- publishJsons
120
+ publishJsons,
121
+ schedulesOps
75
122
  ]).apply(param => {
123
+ let schedulesOps = param[2];
76
124
  let publishJsonsFn = param[1];
77
125
  let queryDbOps = param[0];
78
126
  let ec = SpecificEventCollector.make(Spec.name, eventTopics, {
@@ -82,6 +130,11 @@ function Make(RuntimeEnvironment) {
82
130
  let jsonEventsHandler = stream => Effect.flatMap(Stream.runCollect(stream), jsons => Effect.promise(async () => {
83
131
  Callback.phase1(jsons, context);
84
132
  await syncToQueryDb(queryDbOps);
133
+ let changes = Callback.takeScheduleChanges();
134
+ if (changes.length !== 0) {
135
+ await syncSchedules(schedulesOps, storedSchedules);
136
+ await AutomationSlice_Schedules$ReventlessCore.tell(platform, changes);
137
+ }
85
138
  Stdlib_Promise.$$catch(Callback.phase2(publishJsonsFn).then(() => syncToQueryDb(queryDbOps)), exn => {
86
139
  let errMsg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown");
87
140
  Effect.runSync(EffectLogger$ReventlessCore.logError(`AutomationSlice(` + Spec.name + `)`, undefined, `detached phase 2 error: ` + errMsg));
@@ -96,23 +149,46 @@ function Make(RuntimeEnvironment) {
96
149
  Component$ReventlessCore.setOperations(extra, Pulumi.all([
97
150
  Output$Pulumi.flatMap(eventCollector, Component$ReventlessCore.operations),
98
151
  publishJsons,
99
- Component$ReventlessCore.operations(queryDb)
152
+ Component$ReventlessCore.operations(queryDb),
153
+ schedulesOps
100
154
  ]).apply(param => {
155
+ let schedulesOps = param[3];
101
156
  let queryDbOps = param[2];
102
157
  let publishJsonsFn = param[1];
158
+ let processPending = async () => {
159
+ await Callback.phase2(publishJsonsFn);
160
+ return await syncToQueryDb(queryDbOps);
161
+ };
162
+ let reconcileSchedules = async () => {
163
+ if (readsClock) {
164
+ await AutomationSlice_Schedules$ReventlessCore.reconcile(Callback.schedules, platform, Spec.schedule, Date.now());
165
+ return await syncSchedules(schedulesOps, storedSchedules);
166
+ }
167
+ };
103
168
  return {
104
169
  enqueueEvent: param[0].enqueueEvent,
105
- processPending: async () => {
106
- await Callback.phase2(publishJsonsFn);
107
- return await syncToQueryDb(queryDbOps);
170
+ processPending: processPending,
171
+ tick: async (key, at) => {
172
+ Callback.phase1([AutomationSlice_Callback$ReventlessCore.tickEvent(key, at)], context);
173
+ await syncToQueryDb(queryDbOps);
174
+ await syncSchedules(schedulesOps, storedSchedules);
175
+ await AutomationSlice_Schedules$ReventlessCore.tell(platform, Callback.takeScheduleChanges());
176
+ return await processPending();
177
+ },
178
+ reconcileSchedules: reconcileSchedules,
179
+ rebuildSchedules: async events => {
180
+ Callback.rebuildSchedules(events);
181
+ return await reconcileSchedules();
108
182
  }
109
183
  };
110
184
  }));
111
185
  let aggregatedResources = Object.values(eventTopics).flatMap(t => t.resources);
112
186
  let outputs_queryDb = Component$ReventlessCore.outputs(queryDb);
187
+ let outputs_schedulesDb = Stdlib_Option.map(schedulesDb, Component$ReventlessCore.outputs);
113
188
  let outputs = {
114
189
  resources: aggregatedResources,
115
- queryDb: outputs_queryDb
190
+ queryDb: outputs_queryDb,
191
+ schedulesDb: outputs_schedulesDb
116
192
  };
117
193
  return Component$ReventlessCore.setOutputs(extra, outputs);
118
194
  }, opts);
@@ -120,6 +196,7 @@ function Make(RuntimeEnvironment) {
120
196
  Spec: Spec,
121
197
  Automation: Automation,
122
198
  queryDbName: queryDbName,
199
+ schedulesDbName: schedulesDbName,
123
200
  sourceNames: sourceNames,
124
201
  make: make
125
202
  };
@@ -128,7 +205,7 @@ function Make(RuntimeEnvironment) {
128
205
  finish: EventCollectorRuntimeBuilder.finish,
129
206
  Make: Make
130
207
  };
131
- }))));
208
+ })))));
132
209
  }
133
210
 
134
211
  export {
@@ -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
@@ -201,6 +250,32 @@ module Make = (
201
250
  )
202
251
  }
203
252
 
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
+
204
279
  let phase1 = (events: array<JSON.t>, sliceCtx: Reventless.AutomationSlice.context) => {
205
280
  events->Array.forEach(json => {
206
281
  let context = json->Message.decode(Reventless.Message.contextSchema)
@@ -217,8 +292,38 @@ module Make = (
217
292
  | None => json
218
293
  }
219
294
  // Multiple mappings can share a sourceName; all matching dispatches run.
220
- 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
+ }
307
+ })
308
+ }
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)
221
325
  })
326
+ scheduleChanges := []
222
327
  }
223
328
 
224
329
  let phase2 = async (publishJsons: ReventlessInfra.CommandTopic.publishJsons) => {
@@ -228,6 +333,22 @@ module Make = (
228
333
  // in fact, so say so rather than letting it sit in a status that promises a
229
334
  // retry no pass will make.
230
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
+ )
231
352
  todoItems
232
353
  ->Dict.toArray
233
354
  ->Array.forEach(((id, row)) =>