@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 +20 -0
- package/package.json +6 -6
- 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 +125 -8
- package/src/components/AutomationSlice/AutomationSlice_Builder.res.mjs +86 -9
- package/src/components/AutomationSlice/AutomationSlice_Callback.res +122 -1
- package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +101 -2
- 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/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/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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
204
|
-
|
|
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
|
|
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:
|
|
106
|
-
|
|
107
|
-
|
|
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)
|
|
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)) =>
|