@reventlessdev/reventless-core 3.0.0-alpha.205 → 3.0.0-alpha.207

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,21 @@
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.207 (2026-08-03)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **dcb:** let the produced/consumed check see aggregate producers ([0075360](https://github.com/ReventlessDev/reventless-core/commit/0075360fc5915b166ab25b6acca2c8580e5b4ecb))
11
+ * **geocoding:** calibrate the confidence rule against a real index ([cd72b27](https://github.com/ReventlessDev/reventless-core/commit/cd72b2741460c871db2915a8603f7837b8191187))
12
+
13
+
14
+ # 3.0.0-alpha.206 (2026-08-03)
15
+
16
+ ### Features
17
+
18
+ * **outbound:** let an outbound slice read an aggregate, and geocode addresses with it ([867e63e](https://github.com/ReventlessDev/reventless-core/commit/867e63e774ebc8b78b2b19c78645c8a12a8d06f6))
19
+
20
+
6
21
  # 3.0.0-alpha.205 (2026-08-02)
7
22
 
8
23
  **Note:** Version bump only for package @reventlessdev/reventless-core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.205",
3
+ "version": "3.0.0-alpha.207",
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-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.1",
33
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
34
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
33
  "@reventlessdev/rescript-node": "2.0.0-alpha.1",
34
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.1",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
37
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
37
38
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.1",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.122",
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.124",
39
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.97",
41
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0"
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.99"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.61"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.62"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
@@ -140,6 +140,15 @@ module Make = (
140
140
  aggregates->createAggregatesWithoutEventMappers(~api, ~componentRuntime=Dict.make(), opts)
141
141
  let aggregateEventTopics = Aggregate.allEventTopics(aggregatesWithoutEventMappers)
142
142
 
143
+ // As in Plugin_Builder: what the admin aggregates emit, so a slice naming one
144
+ // as its source is checked against real producers rather than reported as
145
+ // consuming events nothing produces.
146
+ let aggregateProducedEvents =
147
+ aggregates->Array.map((module(A: ReventlessInfra.Aggregate.T with type api = api)) => (
148
+ A.Spec.name,
149
+ A.Spec.eventSchema->S.castToUnknown,
150
+ ))
151
+
143
152
  // Wire CommandGenerator → AppSync / in-memory GraphQL resolvers via
144
153
  // `mutationResolverHook` and populate `aggregateMutationFieldsRegistry`.
145
154
  // The matching SDL entries are owned by
@@ -159,6 +168,7 @@ module Make = (
159
168
  // Domain API — routes the provider hook to `hooks.adminApi`.
160
169
  ~onAdminApi=true,
161
170
  ~aggregateEventTopics,
171
+ ~aggregateProducedEvents,
162
172
  ~stateChangeSlices,
163
173
  ~stateViewSlices,
164
174
  ~automationSlices,
@@ -73,8 +73,12 @@ function Make(RuntimeEnvironment) {
73
73
  });
74
74
  let aggregatesWithoutEventMappers = Builder_Helpers$ReventlessCore.createAggregatesWithoutEventMappers(aggregates, api, {}, opts);
75
75
  let aggregateEventTopics = Aggregate$ReventlessCore.allEventTopics(aggregatesWithoutEventMappers);
76
+ let aggregateProducedEvents = aggregates.map(A => [
77
+ A.Spec.name,
78
+ A.Spec.eventSchema
79
+ ]);
76
80
  Plugin_Helpers$ReventlessCore.registerAdminAggregateMutations(aggregates, Config.hooks);
77
- let dcbResult = DcbBuilder.construct(Platform_Admin_Structure$ReventlessCore.pluginId, Platform_Admin_Structure$ReventlessCore.pluginId, "Platform", true, undefined, undefined, aggregateEventTopics, stateChangeSlices, stateViewSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, undefined, undefined, undefined, opts);
81
+ let dcbResult = DcbBuilder.construct(Platform_Admin_Structure$ReventlessCore.pluginId, Platform_Admin_Structure$ReventlessCore.pluginId, "Platform", true, undefined, undefined, aggregateEventTopics, aggregateProducedEvents, stateChangeSlices, stateViewSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, undefined, undefined, undefined, opts);
78
82
  let slicePublishJsons = dcbResult.dcbPublishJsons;
79
83
  if (slicePublishJsons !== undefined) {
80
84
  stateChangeSlices.forEach(Sc => {
@@ -87,6 +87,13 @@ module Make = (
87
87
  ~environment: string="",
88
88
  ~platformName: string="",
89
89
  ~aggregateEventTopics: EventTopic.allOutputs=Dict.make(),
90
+ // `(Aggregate name, its event schema)` for every aggregate in this plugin.
91
+ // Feeds the produced/consumed check only — an outbound or automation slice
92
+ // may name an Aggregate as a source, and without these its events look like
93
+ // events nothing produces. Deliberately *not* folded into `produced`, which
94
+ // also drives DCB GSI creation: an aggregate's events live on its own
95
+ // EventLog and must not mint indexes on the DCB table.
96
+ ~aggregateProducedEvents: array<(string, S.t<unknown>)>=[],
90
97
  ~stateChangeSlices: array<module(StateChangeSlice.T)>,
91
98
  ~stateViewSlices: array<module(StateViewSlice.T)>,
92
99
  ~automationSlices: array<module(AutomationSlice.T)>,
@@ -145,7 +152,10 @@ module Make = (
145
152
  ),
146
153
  )
147
154
 
148
- switch Reventless.DcbValidation.validateProducedAndConsumed(~produced, ~consumed) {
155
+ switch Reventless.DcbValidation.validateProducedAndConsumed(
156
+ ~produced=produced->Array.concat(aggregateProducedEvents),
157
+ ~consumed,
158
+ ) {
149
159
  | Error(errors) =>
150
160
  errors->Array.forEach(err =>
151
161
  log.error(~comp="Dcb_Builder", `DCB validation error (${err.sliceName}): ${err.message}`)
@@ -621,6 +631,9 @@ module Make = (
621
631
  ->Array.map((module(OTS: OutboundTranslationSlice.T)) => {
622
632
  let ots = OTS.make(
623
633
  ~dcbEventLog,
634
+ // Same dict the AutomationSlices get, so an outbound slice can
635
+ // name an Aggregate source by its Spec.name.
636
+ ~allEventTopics,
624
637
  ~publishJsons,
625
638
  ~runtime=?componentRuntime->Dict.get(OTS.Spec.name),
626
639
  ~opts,
@@ -83,12 +83,13 @@ let emptyResult = {
83
83
 
84
84
  function Make(DcbEventLogStorage) {
85
85
  return DcbEventTopicPublisher => (DcbCommandTopicChannel => (DcbCommandTopicChannelAsync => (RuntimeBuilder => (HooksConfig => {
86
- let construct = (name, childName, apiNamePrefixOpt, onAdminApiOpt, environmentOpt, platformNameOpt, aggregateEventTopicsOpt, stateChangeSlices, stateViewSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, systemCallableComponentsOpt, componentRuntimeOpt, pluginStructure, opts) => {
86
+ let construct = (name, childName, apiNamePrefixOpt, onAdminApiOpt, environmentOpt, platformNameOpt, aggregateEventTopicsOpt, aggregateProducedEventsOpt, stateChangeSlices, stateViewSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, systemCallableComponentsOpt, componentRuntimeOpt, pluginStructure, opts) => {
87
87
  let apiNamePrefix = apiNamePrefixOpt !== undefined ? apiNamePrefixOpt : name;
88
88
  let onAdminApi = onAdminApiOpt !== undefined ? onAdminApiOpt : false;
89
89
  let environment = environmentOpt !== undefined ? environmentOpt : "";
90
90
  let platformName = platformNameOpt !== undefined ? platformNameOpt : "";
91
91
  let aggregateEventTopics = aggregateEventTopicsOpt !== undefined ? aggregateEventTopicsOpt : ({});
92
+ let aggregateProducedEvents = aggregateProducedEventsOpt !== undefined ? aggregateProducedEventsOpt : [];
92
93
  let systemCallableComponents = systemCallableComponentsOpt !== undefined ? systemCallableComponentsOpt : [];
93
94
  let componentRuntime = componentRuntimeOpt !== undefined ? componentRuntimeOpt : ({});
94
95
  let hasDcb = stateChangeSlices.length !== 0;
@@ -119,7 +120,7 @@ function Make(DcbEventLogStorage) {
119
120
  O.Spec.name,
120
121
  O.Spec.consumedEventSchema
121
122
  ]));
122
- let errors = DcbValidation$Reventless.validateProducedAndConsumed(produced, consumed);
123
+ let errors = DcbValidation$Reventless.validateProducedAndConsumed(produced.concat(aggregateProducedEvents), consumed);
123
124
  if (errors.TAG !== "Ok") {
124
125
  errors._0.forEach(err => log.error("Dcb_Builder", undefined, `DCB validation error (` + err.sliceName + `): ` + err.message));
125
126
  }
@@ -329,7 +330,7 @@ function Make(DcbEventLogStorage) {
329
330
  ];
330
331
  }));
331
332
  let outboundTranslationSlicesOutputs = Object.fromEntries(outboundTranslationSlices.map(OTS => {
332
- let ots = OTS.make(dcbEventLog, publishJsons, componentRuntime[OTS.Spec.name], opts);
333
+ let ots = OTS.make(dcbEventLog, allEventTopics, publishJsons, componentRuntime[OTS.Spec.name], opts);
333
334
  return [
334
335
  OTS.Spec.name,
335
336
  Component$ReventlessCore.outputs(ots)
@@ -29,6 +29,7 @@ module type T = {
29
29
 
30
30
  let make: (
31
31
  ~dcbEventLog: DcbEventLog.component,
32
+ ~allEventTopics: EventTopic.allOutputs=?,
32
33
  ~publishJsons: Pulumi.Output.t<CommandTopic.publishJsons>,
33
34
  ~runtime: ReventlessInfra.RuntimeHints.t=?,
34
35
  ~opts: Pulumi.ComponentResource.options=?,
@@ -65,7 +65,14 @@ module Make = (
65
65
  })
66
66
  }
67
67
 
68
- let construct = (~dcbEventLog: DcbEventLog.component, ~publishJsons, ~runtime, self, _name) => {
68
+ let construct = (
69
+ ~dcbEventLog: DcbEventLog.component,
70
+ ~allEventTopics: EventTopic.allOutputs,
71
+ ~publishJsons,
72
+ ~runtime,
73
+ self,
74
+ _name,
75
+ ) => {
69
76
  let opts = {Pulumi.ComponentResource.parent: self->Component.toPulumiResource}
70
77
  let memorySize = ReventlessInfra.RuntimeHints.resolveMemory(runtime, ~default=1024)
71
78
  let timeout = ReventlessInfra.RuntimeHints.resolveTimeout(runtime, ~default=30)
@@ -86,7 +93,38 @@ module Make = (
86
93
  // once per slice instead of collapsing to a single entry.
87
94
  let dcbEventLogName =
88
95
  (dcbEventLog->Component.toPulumiResource).name->Option.getOr("") ++ "DcbEventLog"
89
- let allEventTopics = Dict.fromArray([(dcbEventLogName, dcbEventTopicOutputs)])
96
+
97
+ // Everything this slice could subscribe to: the plugin-wide dict the
98
+ // caller passed, plus its own DCB log — which is always reachable whether
99
+ // or not a dict was supplied, so the `[]` default works in tests and
100
+ // fixtures that construct a slice with nothing else around it.
101
+ let availableTopics = allEventTopics->Dict.copy
102
+ availableTopics->Dict.set(dcbEventLogName, dcbEventTopicOutputs)
103
+
104
+ let sourceNames = switch Spec.sourceNames {
105
+ | [] => [dcbEventLogName]
106
+ | declared => declared
107
+ }
108
+
109
+ // Fail-fast, mirroring AutomationSlice_Builder: an unknown source name
110
+ // would otherwise be dropped by `EventTopic.filter`, leaving the collector
111
+ // subscribed to nothing. That surfaces much later as "the TODO list never
112
+ // populates", with nothing pointing at the typo that caused it.
113
+ sourceNames->Array.forEach(sourceName =>
114
+ if !(availableTopics->Dict.has(sourceName)) {
115
+ let availableNames =
116
+ availableTopics->Dict.keysToArray->Array.toSorted(String.compare)->Array.join(", ")
117
+ JsError.throwWithMessage(
118
+ `OutboundTranslationSlice "${Spec.name}" declares sourceName "${sourceName}", ` ++
119
+ `but no EventTopic with that key exists. ` ++
120
+ `Available source names: [${availableNames}]. ` ++
121
+ `A source is an Aggregate Spec.name or a DCB source name ` ++
122
+ `(typically "<pluginName>DcbEventLog"); \`[]\` means this plugin's own DCB log.`,
123
+ )
124
+ }
125
+ )
126
+
127
+ let eventTopics = availableTopics->EventTopic.filter(sourceNames->Belt.Set.String.fromArray)
90
128
 
91
129
  // Build the EventCollector inside an Output.all2 so both `queryDbOps` and
92
130
  // `publishJsonsFn` are captured in the same closure. The jsonEventsHandler
@@ -99,7 +137,7 @@ module Make = (
99
137
  ->Pulumi.Output.apply(((queryDbOps, publishJsonsFn)) => {
100
138
  let ec = SpecificEventCollector.make(
101
139
  ~name=Spec.name,
102
- ~eventTopics=allEventTopics,
140
+ ~eventTopics,
103
141
  ~owner={kind: ComponentType.OutboundTranslationSlice, name: Spec.name},
104
142
  ~opts,
105
143
  )
@@ -114,14 +152,21 @@ module Make = (
114
152
  // `event` payload — TAG + fields at the top level — not the
115
153
  // wrapper. Splitting the wrapper yields eventType "Unknown",
116
154
  // which DcbDecode drops without warning.
155
+ let envelope = json->JSON.Decode.object
117
156
  let rawEvent =
118
- json
119
- ->JSON.Decode.object
120
- ->Option.flatMap(d => d->Dict.get("event"))
121
- ->Option.getOr(json)
157
+ envelope->Option.flatMap(d => d->Dict.get("event"))->Option.getOr(json)
158
+ // The entity the event was published for. An Aggregate's event
159
+ // payload does not repeat its own id, so without carrying this
160
+ // out of the envelope `collect` cannot name the subject of the
161
+ // outbound item it creates.
162
+ let sourceId =
163
+ envelope
164
+ ->Option.flatMap(d => d->Dict.get("id"))
165
+ ->Option.flatMap(JSON.Decode.string)
166
+ ->Option.getOr("")
122
167
  let (eventType, dataDict) = rawEvent->Message.splitMessage
123
168
  switch decoder.decode(~eventType, ~data=dataDict) {
124
- | Some(event) => [event]
169
+ | Some(event) => [(sourceId, event)]
125
170
  | None => []
126
171
  }
127
172
  },
@@ -164,7 +209,7 @@ module Make = (
164
209
  let resources = (queryDb->Component.outputs).resources
165
210
  ec->EventCollectorRuntimeBuilder.forEventCollector(
166
211
  ~handler,
167
- ~eventTopics=allEventTopics,
212
+ ~eventTopics,
168
213
  ~resources,
169
214
  ~memorySize,
170
215
  ~timeout,
@@ -184,8 +229,12 @@ module Make = (
184
229
  }),
185
230
  )
186
231
 
232
+ // Aggregated across every subscribed topic, as AutomationSlice does — with
233
+ // more than one source, the DCB log's resources alone are an undercount.
234
+ let aggregatedResources =
235
+ eventTopics->Dict.valuesToArray->Array.flatMap((t: EventTopic.outputs) => t.resources)
187
236
  let outputs: OutboundTranslationSlice.outputs = {
188
- resources: dcbEventTopicOutputs.resources,
237
+ resources: aggregatedResources,
189
238
  queryDb: queryDb->Component.outputs,
190
239
  }
191
240
  self->Component.setOutputs(outputs)
@@ -193,6 +242,10 @@ module Make = (
193
242
 
194
243
  let make = (
195
244
  ~dcbEventLog,
245
+ // Defaulted so every existing call site — fixtures included — keeps
246
+ // compiling; a slice with no declared sources needs nothing but its own
247
+ // DCB log, which `construct` adds regardless.
248
+ ~allEventTopics=Dict.make(),
196
249
  ~publishJsons,
197
250
  ~runtime=?,
198
251
  ~opts=?,
@@ -200,7 +253,7 @@ module Make = (
200
253
  Component.make(
201
254
  ~componentType=OutboundTranslationSlice.componentType->ComponentType.toString,
202
255
  ~name=Spec.name,
203
- ~construct=construct(~dcbEventLog, ~publishJsons, ~runtime, ...),
256
+ ~construct=construct(~dcbEventLog, ~allEventTopics, ~publishJsons, ~runtime, ...),
204
257
  ~opts,
205
258
  )
206
259
  }
@@ -10,12 +10,16 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
10
10
  import * as Effect from "effect/Effect";
11
11
  import * as Stream$1 from "effect/Stream";
12
12
  import * as Pulumi from "@pulumi/pulumi";
13
+ import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
14
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
13
15
  import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
14
16
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
17
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
15
18
  import * as DcbDecode$Reventless from "@reventlessdev/reventless-spec/src/components/DcbDecode.res.mjs";
16
19
  import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
17
20
  import * as Message$ReventlessCore from "../../Message.res.mjs";
18
21
  import * as Component$ReventlessCore from "../Component.res.mjs";
22
+ import * as EventTopic$ReventlessCore from "../EventTopic/EventTopic.res.mjs";
19
23
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
20
24
  import * as ComponentType$ReventlessCore from "../../ComponentType.res.mjs";
21
25
  import * as RuntimeHints$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/RuntimeHints.res.mjs";
@@ -50,74 +54,90 @@ function Make(RuntimeEnvironment) {
50
54
  await queryDbOps.save(Id$Reventless.$$String.makeFromString(param[0]), param[1], "Overwrite", undefined);
51
55
  });
52
56
  };
53
- let make = (dcbEventLog, publishJsons, runtime, opts) => Component$ReventlessCore.make(ComponentType$ReventlessCore.toString(OutboundTranslationSlice$ReventlessCore.componentType), Spec.name, (extra, extra$1) => {
54
- let opts_parent = Component$ReventlessCore.toPulumiResource(extra);
55
- let opts = {
56
- parent: opts_parent
57
- };
58
- let memorySize = RuntimeHints$ReventlessInfra.resolveMemory(runtime, 1024);
59
- let timeout = RuntimeHints$ReventlessInfra.resolveTimeout(runtime, 30);
60
- let queryDb = SpecificQueryDb.make(Api.api(), Api.apiRole(), undefined, {
61
- kind: "OutboundTranslationSlice",
62
- name: Spec.name
63
- }, opts);
64
- let dcbEventTopicOutputs = Component$ReventlessCore.outputs(dcbEventLog).eventTopic;
65
- let dcbEventLogName = Stdlib_Option.getOr(Component$ReventlessCore.toPulumiResource(dcbEventLog).__name, "") + "DcbEventLog";
66
- let allEventTopics = Object.fromEntries([[
67
- dcbEventLogName,
68
- dcbEventTopicOutputs
69
- ]]);
70
- let eventCollector = Pulumi.all([
71
- Component$ReventlessCore.operations(queryDb),
72
- publishJsons
73
- ]).apply(param => {
74
- let publishJsonsFn = param[1];
75
- let queryDbOps = param[0];
76
- let ec = SpecificEventCollector.make(Spec.name, allEventTopics, {
57
+ let make = (dcbEventLog, allEventTopicsOpt, publishJsons, runtime, opts) => {
58
+ let allEventTopics = allEventTopicsOpt !== undefined ? allEventTopicsOpt : ({});
59
+ return Component$ReventlessCore.make(ComponentType$ReventlessCore.toString(OutboundTranslationSlice$ReventlessCore.componentType), Spec.name, (extra, extra$1) => {
60
+ let opts_parent = Component$ReventlessCore.toPulumiResource(extra);
61
+ let opts = {
62
+ parent: opts_parent
63
+ };
64
+ let memorySize = RuntimeHints$ReventlessInfra.resolveMemory(runtime, 1024);
65
+ let timeout = RuntimeHints$ReventlessInfra.resolveTimeout(runtime, 30);
66
+ let queryDb = SpecificQueryDb.make(Api.api(), Api.apiRole(), undefined, {
77
67
  kind: "OutboundTranslationSlice",
78
68
  name: Spec.name
79
69
  }, opts);
80
- let jsonEventsHandler = stream => Effect.flatMap(Stream.runCollect(Stream$1.flatMap(Stream$1.mapEffect(stream, json => Effect.sync(() => {
81
- let rawEvent = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), d => d["event"]), json);
82
- let match = Message$ReventlessCore.splitMessage(rawEvent);
83
- let event = decoder.decode(match[0], match[1]);
84
- if (event !== undefined) {
85
- return [Primitive_option.valFromOption(event)];
86
- } else {
87
- return [];
70
+ let dcbEventTopicOutputs = Component$ReventlessCore.outputs(dcbEventLog).eventTopic;
71
+ let dcbEventLogName = Stdlib_Option.getOr(Component$ReventlessCore.toPulumiResource(dcbEventLog).__name, "") + "DcbEventLog";
72
+ let availableTopics = Object.assign({}, allEventTopics);
73
+ availableTopics[dcbEventLogName] = dcbEventTopicOutputs;
74
+ let declared = Spec.sourceNames;
75
+ let sourceNames = declared.length !== 0 ? declared : [dcbEventLogName];
76
+ sourceNames.forEach(sourceName => {
77
+ if (sourceName in availableTopics) {
78
+ return;
88
79
  }
89
- })), events => Stream$1.fromIterable(events))), eventsArr => Effect.promise(async () => {
90
- Callback.phase1(eventsArr);
91
- await syncToQueryDb(queryDbOps);
92
- Stdlib_Promise.$$catch(Callback.phase2(publishJsonsFn).then(() => syncToQueryDb(queryDbOps)), exn => {
93
- let errMsg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown");
94
- Effect.runSync(EffectLogger$ReventlessCore.logError(`OutboundTranslationSlice(` + Spec.name + `)`, undefined, `detached phase 2 error: ` + errMsg));
95
- return Promise.resolve();
96
- });
80
+ let availableNames = Object.keys(availableTopics).toSorted(Primitive_string.compare).join(", ");
81
+ Stdlib_JsError.throwWithMessage(`OutboundTranslationSlice "` + Spec.name + `" declares sourceName "` + sourceName + `", but no EventTopic with that key exists. ` + (`Available source names: [` + availableNames + `]. `) + `A source is an Aggregate Spec.name or a DCB source name (typically "<pluginName>DcbEventLog"); \`[]\` means this plugin's own DCB log.`);
82
+ });
83
+ let eventTopics = EventTopic$ReventlessCore.filter(availableTopics, Belt_SetString.fromArray(sourceNames));
84
+ let eventCollector = Pulumi.all([
85
+ Component$ReventlessCore.operations(queryDb),
86
+ publishJsons
87
+ ]).apply(param => {
88
+ let publishJsonsFn = param[1];
89
+ let queryDbOps = param[0];
90
+ let ec = SpecificEventCollector.make(Spec.name, eventTopics, {
91
+ kind: "OutboundTranslationSlice",
92
+ name: Spec.name
93
+ }, opts);
94
+ let jsonEventsHandler = stream => Effect.flatMap(Stream.runCollect(Stream$1.flatMap(Stream$1.mapEffect(stream, json => Effect.sync(() => {
95
+ let envelope = Stdlib_JSON.Decode.object(json);
96
+ let rawEvent = Stdlib_Option.getOr(Stdlib_Option.flatMap(envelope, d => d["event"]), json);
97
+ let sourceId = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(envelope, d => d["id"]), Stdlib_JSON.Decode.string), "");
98
+ let match = Message$ReventlessCore.splitMessage(rawEvent);
99
+ let event = decoder.decode(match[0], match[1]);
100
+ if (event !== undefined) {
101
+ return [[
102
+ sourceId,
103
+ Primitive_option.valFromOption(event)
104
+ ]];
105
+ } else {
106
+ return [];
107
+ }
108
+ })), events => Stream$1.fromIterable(events))), eventsArr => Effect.promise(async () => {
109
+ Callback.phase1(eventsArr);
110
+ await syncToQueryDb(queryDbOps);
111
+ Stdlib_Promise.$$catch(Callback.phase2(publishJsonsFn).then(() => syncToQueryDb(queryDbOps)), exn => {
112
+ let errMsg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown");
113
+ Effect.runSync(EffectLogger$ReventlessCore.logError(`OutboundTranslationSlice(` + Spec.name + `)`, undefined, `detached phase 2 error: ` + errMsg));
114
+ return Promise.resolve();
115
+ });
116
+ }));
117
+ let handler = SpecificEventCollector.makeHandler(ec, jsonEventsHandler);
118
+ let resources = Component$ReventlessCore.outputs(queryDb).resources;
119
+ EventCollectorRuntimeBuilder.forEventCollector(handler, eventTopics, resources, memorySize, timeout, ec);
120
+ return ec;
121
+ });
122
+ Component$ReventlessCore.setOperations(extra, Pulumi.all([
123
+ Output$Pulumi.flatMap(eventCollector, Component$ReventlessCore.operations),
124
+ publishJsons
125
+ ]).apply(param => {
126
+ let publishJsonsFn = param[1];
127
+ return {
128
+ enqueueEvent: param[0].enqueueEvent,
129
+ translatePending: async () => await Callback.phase2(publishJsonsFn)
130
+ };
97
131
  }));
98
- let handler = SpecificEventCollector.makeHandler(ec, jsonEventsHandler);
99
- let resources = Component$ReventlessCore.outputs(queryDb).resources;
100
- EventCollectorRuntimeBuilder.forEventCollector(handler, allEventTopics, resources, memorySize, timeout, ec);
101
- return ec;
102
- });
103
- Component$ReventlessCore.setOperations(extra, Pulumi.all([
104
- Output$Pulumi.flatMap(eventCollector, Component$ReventlessCore.operations),
105
- publishJsons
106
- ]).apply(param => {
107
- let publishJsonsFn = param[1];
108
- return {
109
- enqueueEvent: param[0].enqueueEvent,
110
- translatePending: async () => await Callback.phase2(publishJsonsFn)
132
+ let aggregatedResources = Object.values(eventTopics).flatMap(t => t.resources);
133
+ let outputs_queryDb = Component$ReventlessCore.outputs(queryDb);
134
+ let outputs = {
135
+ resources: aggregatedResources,
136
+ queryDb: outputs_queryDb
111
137
  };
112
- }));
113
- let outputs_resources = dcbEventTopicOutputs.resources;
114
- let outputs_queryDb = Component$ReventlessCore.outputs(queryDb);
115
- let outputs = {
116
- resources: outputs_resources,
117
- queryDb: outputs_queryDb
118
- };
119
- return Component$ReventlessCore.setOutputs(extra, outputs);
120
- }, opts);
138
+ return Component$ReventlessCore.setOutputs(extra, outputs);
139
+ }, opts);
140
+ };
121
141
  return {
122
142
  Spec: Spec,
123
143
  Translation: Translation,
@@ -29,8 +29,10 @@ module type T = {
29
29
  /** The in-memory TODO list -- maps item ID to row. */
30
30
  let todoItems: Dict.t<todoRow>
31
31
 
32
- /** Phase 1: update TODO list from a batch of events (collect only, no resolve). */
33
- let phase1: array<Spec.consumedEvent> => unit
32
+ /** Phase 1: update TODO list from a batch of `(sourceId, event)` pairs
33
+ (collect only, no resolve). `sourceId` is the envelope id of the entity the
34
+ event was published for. */
35
+ let phase1: array<(string, Spec.consumedEvent)> => unit
34
36
 
35
37
  /** Phase 2: translate all pending items, optionally publishing commands via publishJsons. */
36
38
  let phase2: ReventlessInfra.CommandTopic.publishJsons => promise<unit>
@@ -54,10 +56,10 @@ module Make = (
54
56
  let makeMeta = (): Reventless.Message.meta =>
55
57
  Message.generateMeta(~service=`OutboundTranslationSlice:${Spec.name}`)
56
58
 
57
- let phase1 = (events: array<Spec.consumedEvent>) => {
58
- events->Array.forEach(event => {
59
+ let phase1 = (events: array<(string, Spec.consumedEvent)>) => {
60
+ events->Array.forEach(((sourceId, event)) => {
59
61
  // Collect new outbound items
60
- Translation.collect(event)->Array.forEach(((id, item)) => {
62
+ Translation.collect(event, ~sourceId)->Array.forEach(((id, item)) => {
61
63
  switch todoItems->Dict.get(id) {
62
64
  | Some(_) => () // Already exists -- skip (idempotent)
63
65
  | None =>
@@ -34,8 +34,8 @@ function Make(Spec) {
34
34
  let todoItems = {};
35
35
  let makeMeta = () => Message$ReventlessCore.generateMeta(`OutboundTranslationSlice:` + Spec.name, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
36
36
  let phase1 = events => {
37
- events.forEach(event => {
38
- Translation.collect(event).forEach(param => {
37
+ events.forEach(param => {
38
+ Translation.collect(param[1], param[0]).forEach(param => {
39
39
  let id = param[0];
40
40
  let match = todoItems[id];
41
41
  if (match !== undefined) {
@@ -143,6 +143,15 @@ module Make = (
143
143
  aggregates->createAggregatesWithoutEventMappers(~api, ~componentRuntime, opts)
144
144
  let aggregateEventTopics = Aggregate.allEventTopics(aggregatesWithoutEventMappers)
145
145
 
146
+ // What each aggregate emits, for the DCB produced/consumed check. A slice
147
+ // naming an Aggregate as its source consumes those events, and the check
148
+ // otherwise sees no producer for them and reports correct code as broken.
149
+ let aggregateProducedEvents =
150
+ aggregates->Array.map((module(A: ReventlessInfra.Aggregate.T with type api = api)) => (
151
+ A.Spec.name,
152
+ A.Spec.eventSchema->S.castToUnknown,
153
+ ))
154
+
146
155
  // Construct DCB components and derive DCB-specific API schema entries
147
156
  module DcbBuilder = Dcb_Builder.Make(
148
157
  DcbEventLogStorage,
@@ -158,6 +167,7 @@ module Make = (
158
167
  ~environment=Spec.environment,
159
168
  ~platformName=Spec.platformName,
160
169
  ~aggregateEventTopics,
170
+ ~aggregateProducedEvents,
161
171
  ~stateChangeSlices,
162
172
  ~stateViewSlices,
163
173
  ~automationSlices,
@@ -149,10 +149,14 @@ function Make(Spec) {
149
149
  let childName = ComponentType$ReventlessCore.name(extra$1, Plugin$ReventlessCore.componentType);
150
150
  let aggregatesWithoutEventMappers = Plugin_Helpers$ReventlessCore.createAggregatesWithoutEventMappers(aggregates, api, componentRuntime, opts);
151
151
  let aggregateEventTopics = Aggregate$ReventlessCore.allEventTopics(aggregatesWithoutEventMappers);
152
+ let aggregateProducedEvents = aggregates.map(A => [
153
+ A.Spec.name,
154
+ A.Spec.eventSchema
155
+ ]);
152
156
  let DcbBuilder = Dcb_Builder$ReventlessCore.Make(DcbEventLogStorage)(DcbEventTopicPublisher)(DcbCommandTopicChannel)(DcbCommandTopicChannelAsync)(PluginRuntimeBuilder)({
153
157
  hooks: Spec.hooks
154
158
  });
155
- let dcbResult = DcbBuilder.construct(extra$1, childName, undefined, undefined, Spec.environment, Spec.platformName, aggregateEventTopics, stateChangeSlices, stateViewSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, systemCallableComponents, componentRuntime, pluginStructure, opts);
159
+ let dcbResult = DcbBuilder.construct(extra$1, childName, undefined, undefined, Spec.environment, Spec.platformName, aggregateEventTopics, aggregateProducedEvents, stateChangeSlices, stateViewSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, systemCallableComponents, componentRuntime, pluginStructure, opts);
156
160
  let mutationEntriesFromAggregates = aggregates.flatMap(M => {
157
161
  let commandSchema = M.Spec.commandSchema;
158
162
  if (ApiNoApiHelpers$ReventlessCore.isNoApi(commandSchema)) {
@@ -0,0 +1,96 @@
1
+ open JestGlobals
2
+
3
+ // `confidentMatch` is the gate between "the geocoder said something" and "a
4
+ // coordinate is written into an event log". Every case it declines is a
5
+ // plausible-looking pin it refused to draw, so the declines are the assertions
6
+ // that matter here.
7
+ //
8
+ // It is provider-neutral policy (`Reventless.Geocoding`), shared by the SDK-backed
9
+ // transport in this package and by any plugin reaching a geocoder over HTTP.
10
+
11
+ module Geocoding = Reventless.Geocoding
12
+
13
+ let point = (~lat, ~lng) =>
14
+ switch Reventless.GeoPoint.make(~lat, ~lng) {
15
+ | Ok(p) => p
16
+ | Error(why) => fail(why)
17
+ }
18
+
19
+ let candidate = (~label, ~relevance=?): Geocoding.candidate => {
20
+ label,
21
+ point: point(~lat=48.2082, ~lng=16.3738),
22
+ relevance,
23
+ }
24
+
25
+ describe("Geocoder_AwsLocation_Geocoding.confidentMatch", () => {
26
+ testSync("a clear winner is the answer", () => {
27
+ let top = candidate(~label="Stephansplatz 1, Vienna", ~relevance=1.0)
28
+ let result = Geocoding.confidentMatch([top, candidate(~label="Elsewhere", ~relevance=0.4)])
29
+ expect(result->Option.map(c => c.Geocoding.label))->toEqual(Some("Stephansplatz 1, Vienna"))
30
+ })
31
+
32
+ testSync("a loose top match is declined", () => {
33
+ let result = Geocoding.confidentMatch([candidate(~label="Somewhere-ish", ~relevance=0.55)])
34
+ expect(result->Option.isNone)->toBe(true)
35
+ })
36
+
37
+ testSync("two near-equal candidates are ambiguous, not a winner", () => {
38
+ // The bare-town-name case, with the scores the live Esri index actually
39
+ // returns for it: five states at exactly 1.0. Genuine ambiguity there is a
40
+ // tie, not a near miss — which is why the margin is small and this case is
41
+ // still caught.
42
+ let result = Geocoding.confidentMatch([
43
+ candidate(~label="Springfield, IL", ~relevance=1.0),
44
+ candidate(~label="Springfield, MA", ~relevance=1.0),
45
+ ])
46
+ expect(result->Option.isNone)->toBe(true)
47
+ })
48
+
49
+ testSync("a correct match with a plausible runner-up is still a winner", () => {
50
+ // Measured against the live index: "Baker Street 221B, London NW1 6XE"
51
+ // returns the right building — matching postcode — at 0.991, with an
52
+ // unrelated Baker Street at 0.962. The previous calibration (floor 0.8,
53
+ // margin 0.1) read that 0.029 gap as ambiguity and declined, so a perfectly
54
+ // good address was written off as unresolvable and, per the retry policy,
55
+ // never tried again. Three of twelve realistic addresses failed this way.
56
+ let result = Geocoding.confidentMatch([
57
+ candidate(~label="221 Baker Street, Marylebone, London NW1 6XE", ~relevance=0.991),
58
+ candidate(~label="Baker Street, elsewhere", ~relevance=0.962),
59
+ ])
60
+ expect(result->Option.map(c => c.Geocoding.label))->toEqual(
61
+ Some("221 Baker Street, Marylebone, London NW1 6XE"),
62
+ )
63
+ })
64
+
65
+ testSync("a wrong street in the right city is declined", () => {
66
+ // The other half of the same measurement, and the reason the floor moved up
67
+ // rather than the margin merely moving down: "Kaiserstrasse 12, 4020 Linz"
68
+ // resolves to *Rainerstrasse* 12 in Linz at 0.958. Its gap to the runner-up
69
+ // is 0.031 — wider than the correct match above — so no margin can separate
70
+ // the two. Only the absolute score can.
71
+ let result = Geocoding.confidentMatch([
72
+ candidate(~label="Rainerstraße 12, 4020 Linz", ~relevance=0.958),
73
+ candidate(~label="Somewhere else, Linz", ~relevance=0.927),
74
+ ])
75
+ expect(result->Option.isNone)->toBe(true)
76
+ })
77
+
78
+ testSync("an unscored candidate is not confident", () => {
79
+ // An index that reports no relevance cannot support an unattended decision;
80
+ // accepting it would make the threshold meaningless where it matters most.
81
+ let result = Geocoding.confidentMatch([candidate(~label="Unscored")])
82
+ expect(result->Option.isNone)->toBe(true)
83
+ })
84
+
85
+ testSync("no candidates, no match", () => {
86
+ expect(Geocoding.confidentMatch([])->Option.isNone)->toBe(true)
87
+ })
88
+
89
+ testSync("the thresholds are callable, not baked in", () => {
90
+ let loose = [candidate(~label="Loose", ~relevance=0.62)]
91
+ expect(Geocoding.confidentMatch(loose)->Option.isNone)->toBe(true)
92
+ expect(
93
+ Geocoding.confidentMatch(loose, ~minRelevance=0.5)->Option.map(c => c.Geocoding.label),
94
+ )->toEqual(Some("Loose"))
95
+ })
96
+ })
@@ -0,0 +1,80 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as JestGlobals from "@reventlessdev/rescript-jest/src/JestGlobals.res.mjs";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as GeoPoint$Reventless from "@reventlessdev/reventless-spec/src/semantic/GeoPoint.res.mjs";
6
+ import * as Geocoding$Reventless from "@reventlessdev/reventless-spec/src/semantic/Geocoding.res.mjs";
7
+
8
+ function point(lat, lng) {
9
+ let p = GeoPoint$Reventless.make(lat, lng);
10
+ if (p.TAG === "Ok") {
11
+ return p._0;
12
+ } else {
13
+ return JestGlobals.fail(p._0);
14
+ }
15
+ }
16
+
17
+ function candidate(label, relevance) {
18
+ return {
19
+ label: label,
20
+ point: point(48.2082, 16.3738),
21
+ relevance: relevance
22
+ };
23
+ }
24
+
25
+ globalThis.describe("Geocoder_AwsLocation_Geocoding.confidentMatch", () => {
26
+ globalThis.test("a clear winner is the answer", () => {
27
+ let top = candidate("Stephansplatz 1, Vienna", 1.0);
28
+ let result = Geocoding$Reventless.confidentMatch([
29
+ top,
30
+ candidate("Elsewhere", 0.4)
31
+ ], undefined, undefined);
32
+ globalThis.expect(Stdlib_Option.map(result, c => c.label)).toEqual("Stephansplatz 1, Vienna");
33
+ });
34
+ globalThis.test("a loose top match is declined", () => {
35
+ let result = Geocoding$Reventless.confidentMatch([candidate("Somewhere-ish", 0.55)], undefined, undefined);
36
+ globalThis.expect(Stdlib_Option.isNone(result)).toBe(true);
37
+ });
38
+ globalThis.test("two near-equal candidates are ambiguous, not a winner", () => {
39
+ let result = Geocoding$Reventless.confidentMatch([
40
+ candidate("Springfield, IL", 1.0),
41
+ candidate("Springfield, MA", 1.0)
42
+ ], undefined, undefined);
43
+ globalThis.expect(Stdlib_Option.isNone(result)).toBe(true);
44
+ });
45
+ globalThis.test("a correct match with a plausible runner-up is still a winner", () => {
46
+ let result = Geocoding$Reventless.confidentMatch([
47
+ candidate("221 Baker Street, Marylebone, London NW1 6XE", 0.991),
48
+ candidate("Baker Street, elsewhere", 0.962)
49
+ ], undefined, undefined);
50
+ globalThis.expect(Stdlib_Option.map(result, c => c.label)).toEqual("221 Baker Street, Marylebone, London NW1 6XE");
51
+ });
52
+ globalThis.test("a wrong street in the right city is declined", () => {
53
+ let result = Geocoding$Reventless.confidentMatch([
54
+ candidate("Rainerstraße 12, 4020 Linz", 0.958),
55
+ candidate("Somewhere else, Linz", 0.927)
56
+ ], undefined, undefined);
57
+ globalThis.expect(Stdlib_Option.isNone(result)).toBe(true);
58
+ });
59
+ globalThis.test("an unscored candidate is not confident", () => {
60
+ let result = Geocoding$Reventless.confidentMatch([candidate("Unscored", undefined)], undefined, undefined);
61
+ globalThis.expect(Stdlib_Option.isNone(result)).toBe(true);
62
+ });
63
+ globalThis.test("no candidates, no match", () => {
64
+ globalThis.expect(Stdlib_Option.isNone(Geocoding$Reventless.confidentMatch([], undefined, undefined))).toBe(true);
65
+ });
66
+ globalThis.test("the thresholds are callable, not baked in", () => {
67
+ let loose = [candidate("Loose", 0.62)];
68
+ globalThis.expect(Stdlib_Option.isNone(Geocoding$Reventless.confidentMatch(loose, undefined, undefined))).toBe(true);
69
+ globalThis.expect(Stdlib_Option.map(Geocoding$Reventless.confidentMatch(loose, 0.5, undefined), c => c.label)).toEqual("Loose");
70
+ });
71
+ });
72
+
73
+ let Geocoding;
74
+
75
+ export {
76
+ Geocoding,
77
+ point,
78
+ candidate,
79
+ }
80
+ /* Not a pure module */