@reventlessdev/reventless-spec 3.0.0-alpha.96 → 3.0.0-alpha.98
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 +15 -0
- package/package.json +1 -1
- package/src/components/OutboundTranslationSlice.res +31 -1
- package/src/components/Plugin.res.mjs +2 -2
- package/src/semantic/Geocoding.res +101 -0
- package/src/semantic/Geocoding.res.mjs +33 -0
- package/src/semantic/Offload.res +42 -3
- package/src/semantic/Offload.res.mjs +33 -4
- package/src/semantic/Semantic.res +6 -3
- package/src/semantic/StorageRef.res +1 -1
- package/src/semantic/StorageRef.res.mjs +2 -1
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.98 (2026-08-03)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **aws:** let a deployment hand its plugins a geocoder ([143bf41](https://github.com/ReventlessDev/reventless-core/commit/143bf4137765362ce07dd42db0f4a57057da9f13))
|
|
11
|
+
* **outbound:** let an outbound slice read an aggregate, and geocode addresses with it ([867e63e](https://github.com/ReventlessDev/reventless-core/commit/867e63e774ebc8b78b2b19c78645c8a12a8d06f6))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.97 (2026-08-02)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **ppx:** add [@offload](https://github.com/offload) field shorthand with per-field threshold ([3d5e3b5](https://github.com/ReventlessDev/reventless-core/commit/3d5e3b5da5010547ce0eaf7d94d660daec67feed))
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.96 (2026-08-02)
|
|
7
22
|
|
|
8
23
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -76,6 +76,29 @@ module type Spec = {
|
|
|
76
76
|
/** Name of the aggregate or StateChangeSlice that receives the inbound command, or None for fire-and-forget. */
|
|
77
77
|
let targetName: option<string>
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
Event sources this slice subscribes to, by topic key.
|
|
81
|
+
|
|
82
|
+
`[]` — the default and the historical behaviour — means this plugin's own DCB
|
|
83
|
+
event log. Naming sources explicitly subscribes to them instead: an Aggregate's
|
|
84
|
+
`Spec.name`, or a DCB source name (conventionally `"<pluginName>DcbEventLog"`),
|
|
85
|
+
matching the keys `AutomationSlice` mappings already use.
|
|
86
|
+
|
|
87
|
+
This exists because an outbound slice is the framework's one component for
|
|
88
|
+
*calling an external service and feeding the answer back*, and that job is not
|
|
89
|
+
specific to DCB-modelled entities. An Aggregate whose events should trigger an
|
|
90
|
+
outbound call had no route to one while this list was hard-wired.
|
|
91
|
+
|
|
92
|
+
Unlike `AutomationSlice`, the sources are a flat list rather than per-source
|
|
93
|
+
`Mapping` modules. An automation needs a `resolve` per source (a different
|
|
94
|
+
event completes the item depending on where it came from); an outbound item is
|
|
95
|
+
resolved by its own `translate` succeeding, so the only thing that varies per
|
|
96
|
+
source is the decode — and the one `consumedEvent` union already covers that.
|
|
97
|
+
The cost of the flat form is that two sources sharing an event-type name are
|
|
98
|
+
indistinguishable; declare only the sources whose events you mean.
|
|
99
|
+
*/
|
|
100
|
+
let sourceNames: array<string>
|
|
101
|
+
|
|
79
102
|
/** Optional display name of the foreign system this anti-corruption slice publishes
|
|
80
103
|
to (e.g. `"EmailService"`). Drives the **external box** drawn outside the plugin
|
|
81
104
|
in the Event Graph / Context Map (see docs/plans/translation-external-boxes.md).
|
|
@@ -94,8 +117,15 @@ module type Translation = {
|
|
|
94
117
|
Collect: map an incoming event to zero or more new outbound items.
|
|
95
118
|
Each item has an `id` (deduplication key) and the `outboundItem` payload.
|
|
96
119
|
Returns empty array if this event is not relevant.
|
|
120
|
+
|
|
121
|
+
`~sourceId` is the id of the entity the event was published for — the envelope's
|
|
122
|
+
`id`, not part of the event payload. A DCB event usually names its own subject
|
|
123
|
+
in the payload (`OrderPlaced({orderId, …})`) and can ignore this; an Aggregate's
|
|
124
|
+
event generally does not, because the aggregate id is what addressed it in the
|
|
125
|
+
first place. Without this the outbound item for `Registered({email, address})`
|
|
126
|
+
would have no way to say *which customer* it is for.
|
|
97
127
|
*/
|
|
98
|
-
let collect: Spec.consumedEvent => array<(string, Spec.outboundItem)>
|
|
128
|
+
let collect: (Spec.consumedEvent, ~sourceId: string) => array<(string, Spec.outboundItem)>
|
|
99
129
|
|
|
100
130
|
/**
|
|
101
131
|
Translate: call the external service for a single outbound item.
|
|
@@ -44,7 +44,7 @@ let apiTargetSchema = S.union([
|
|
|
44
44
|
S.literal("Platform")
|
|
45
45
|
]);
|
|
46
46
|
|
|
47
|
-
let apiSchemaFragmentOffloadSchema = Offload$Reventless.optionSchema(undefined, "pluginApiFragments", apiSchemaFragmentSchema);
|
|
47
|
+
let apiSchemaFragmentOffloadSchema = Offload$Reventless.optionSchema(undefined, "pluginApiFragments", undefined, apiSchemaFragmentSchema);
|
|
48
48
|
|
|
49
49
|
let dcbEventLogOptionSchema = SuryResMjs.js_nullable(dcbEventLogDefinitionSchema);
|
|
50
50
|
|
|
@@ -202,7 +202,7 @@ let pluginStructureSchema = S.schema(s => ({
|
|
|
202
202
|
requiredStoreDeclarations: s.m(requiredStoreDeclarationArrayOptionSchema)
|
|
203
203
|
}));
|
|
204
204
|
|
|
205
|
-
let pluginStructureOffloadSchema = Offload$Reventless.optionSchema(undefined, "pluginStructures", pluginStructureSchema);
|
|
205
|
+
let pluginStructureOffloadSchema = Offload$Reventless.optionSchema(undefined, "pluginStructures", undefined, pluginStructureSchema);
|
|
206
206
|
|
|
207
207
|
let pluginDefinitionSchema = S.schema(s => ({
|
|
208
208
|
id: s.m(S.string),
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Turning an address into a point, and deciding whether to believe the answer.
|
|
3
|
+
|
|
4
|
+
The *transport* is provider-specific — Amazon Location, a self-hosted Nominatim,
|
|
5
|
+
a vendor's HTTP API — and lives with its provider. What lives here is everything
|
|
6
|
+
that is the same regardless of who answers: the shape of an answer, the two ways
|
|
7
|
+
a lookup can fail, and the rule for when a ranked list is confident enough to
|
|
8
|
+
write a coordinate into an event log.
|
|
9
|
+
|
|
10
|
+
That split matters because the confidence rule is the part that is easy to get
|
|
11
|
+
wrong and expensive to get wrong twice. A caller that re-derives it — takes
|
|
12
|
+
`results[0]` because the list was ranked — produces a plausible marker in the
|
|
13
|
+
wrong region, drawn without an error anywhere. Deciding it once, here, is what
|
|
14
|
+
stops each transport inventing its own answer.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One candidate a geocoder returned. */
|
|
18
|
+
type candidate = {
|
|
19
|
+
/** The provider's canonical rendering of the address it matched. */
|
|
20
|
+
label: string,
|
|
21
|
+
point: GeoPoint.t,
|
|
22
|
+
/**
|
|
23
|
+
How well this result matches the query, 0…1.
|
|
24
|
+
|
|
25
|
+
`None` when the provider does not score its results. That is not the same as a
|
|
26
|
+
low score and must not be read as a high one — `confidentMatch` declines it,
|
|
27
|
+
because an unscored list cannot support an unattended decision.
|
|
28
|
+
*/
|
|
29
|
+
relevance: option<float>,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
Why a lookup produced no usable point.
|
|
34
|
+
|
|
35
|
+
Two constructors rather than one message because the caller's retry decision
|
|
36
|
+
turns on exactly this distinction, and a `switch` is the only form of it that
|
|
37
|
+
cannot be got wrong. A translator that cannot tell "no such address" from "the
|
|
38
|
+
service is down" turns one outage into a permanent verdict on every address in
|
|
39
|
+
flight.
|
|
40
|
+
*/
|
|
41
|
+
type failure =
|
|
42
|
+
| /** The provider could not be reached, or refused the call. Retry. */
|
|
43
|
+
Unavailable(string)
|
|
44
|
+
| /** The provider answered, and had nothing for this text. Do not retry. */
|
|
45
|
+
NoMatch
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
The port a caller reaches a geocoder through.
|
|
49
|
+
|
|
50
|
+
Everything above says what an answer looks like; this says how one is asked for.
|
|
51
|
+
Written down as a type before anything is injected against it, so that swapping
|
|
52
|
+
the implementation underneath a caller is a change of *supplier* rather than a
|
|
53
|
+
change of call site: whatever eventually hands a translator its geocoder — an
|
|
54
|
+
HTTP client reading an endpoint out of its environment, a provider SDK called
|
|
55
|
+
directly — has to satisfy this, and the `await search(~text=…)` in the caller
|
|
56
|
+
does not move.
|
|
57
|
+
|
|
58
|
+
`~text` is the address as a human typed it, unnormalised. Normalising it is the
|
|
59
|
+
provider's job, and its canonical rendering comes back as `candidate.label`.
|
|
60
|
+
*/
|
|
61
|
+
type search = (~text: string) => promise<result<array<candidate>, failure>>
|
|
62
|
+
|
|
63
|
+
/** The default confidence floor. A starting point, not a finding — the first
|
|
64
|
+
real corpus of addresses is what should set it. */
|
|
65
|
+
let defaultMinRelevance = 0.8
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
The one candidate confident enough to store, or `None`.
|
|
69
|
+
|
|
70
|
+
Two ways to be unsure, and both decline:
|
|
71
|
+
|
|
72
|
+
- the top candidate scores below `minRelevance` — the provider matched
|
|
73
|
+
something, loosely;
|
|
74
|
+
- the runner-up scores within `ambiguityMargin` of the top — the provider
|
|
75
|
+
matched several things about equally well, which is what a bare town name
|
|
76
|
+
does.
|
|
77
|
+
|
|
78
|
+
`None` means "ask a human", not "no result". The candidates are still there for
|
|
79
|
+
a caller that wants to show them.
|
|
80
|
+
*/
|
|
81
|
+
let confidentMatch = (
|
|
82
|
+
candidates: array<candidate>,
|
|
83
|
+
~minRelevance: float=defaultMinRelevance,
|
|
84
|
+
~ambiguityMargin: float=0.1,
|
|
85
|
+
): option<candidate> =>
|
|
86
|
+
switch candidates->Array.get(0) {
|
|
87
|
+
| None => None
|
|
88
|
+
| Some(top) =>
|
|
89
|
+
switch top.relevance {
|
|
90
|
+
| None => None
|
|
91
|
+
| Some(topScore) =>
|
|
92
|
+
if topScore < minRelevance {
|
|
93
|
+
None
|
|
94
|
+
} else {
|
|
95
|
+
switch candidates->Array.get(1)->Option.flatMap(c => c.relevance) {
|
|
96
|
+
| Some(runnerUp) if topScore -. runnerUp < ambiguityMargin => None
|
|
97
|
+
| _ => Some(top)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
|
|
5
|
+
function confidentMatch(candidates, minRelevanceOpt, ambiguityMarginOpt) {
|
|
6
|
+
let minRelevance = minRelevanceOpt !== undefined ? minRelevanceOpt : 0.8;
|
|
7
|
+
let ambiguityMargin = ambiguityMarginOpt !== undefined ? ambiguityMarginOpt : 0.1;
|
|
8
|
+
let top = candidates[0];
|
|
9
|
+
if (top === undefined) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
let topScore = top.relevance;
|
|
13
|
+
if (topScore === undefined) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (topScore < minRelevance) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let runnerUp = Stdlib_Option.flatMap(candidates[1], c => c.relevance);
|
|
20
|
+
if (runnerUp !== undefined && topScore - runnerUp < ambiguityMargin) {
|
|
21
|
+
return;
|
|
22
|
+
} else {
|
|
23
|
+
return top;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let defaultMinRelevance = 0.8;
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
defaultMinRelevance,
|
|
31
|
+
confidentMatch,
|
|
32
|
+
}
|
|
33
|
+
/* No side effect */
|
package/src/semantic/Offload.res
CHANGED
|
@@ -127,8 +127,13 @@ plugin's own store; qualify as `"<plugin>.<store>"` to point at another's.
|
|
|
127
127
|
|
|
128
128
|
Prefer the `@offload("<store>")` ppx shorthand over calling this by hand.
|
|
129
129
|
*/
|
|
130
|
-
let forStore = (
|
|
131
|
-
|
|
130
|
+
let forStore = (
|
|
131
|
+
~plugin: option<string>=?,
|
|
132
|
+
~store: string,
|
|
133
|
+
~threshold: option<int>=?,
|
|
134
|
+
inner: S.t<'a>,
|
|
135
|
+
): S.t<payload<'a>> =>
|
|
136
|
+
schema(inner)->Semantic.mark(~id=Semantic.Id.offload, ~payload=StoredIn({plugin, store, threshold}))
|
|
132
137
|
|
|
133
138
|
/**
|
|
134
139
|
The codec wrapped for an **optional** field (`js_nullable`), plus the `StoredIn`
|
|
@@ -139,11 +144,12 @@ for older protocol versions, say). The marker sits on the outer schema, where
|
|
|
139
144
|
let optionSchema = (
|
|
140
145
|
~plugin: option<string>=?,
|
|
141
146
|
~store: string,
|
|
147
|
+
~threshold: option<int>=?,
|
|
142
148
|
inner: S.t<'a>,
|
|
143
149
|
): S.t<option<payload<'a>>> =>
|
|
144
150
|
_jsNullable(schema(inner), ())->Semantic.mark(
|
|
145
151
|
~id=Semantic.Id.offload,
|
|
146
|
-
~payload=StoredIn({plugin, store}),
|
|
152
|
+
~payload=StoredIn({plugin, store, threshold}),
|
|
147
153
|
)
|
|
148
154
|
|
|
149
155
|
/** Serialize a payload to its **untagged** wire JSON: `Inline` becomes the bare
|
|
@@ -253,3 +259,36 @@ let getStore = (schema: S.t<'a>): option<Semantic.storeTarget> =>
|
|
|
253
259
|
| Some({id, payload: StoredIn(target)}) if id == Semantic.Id.offload => Some(target)
|
|
254
260
|
| _ => None
|
|
255
261
|
}
|
|
262
|
+
|
|
263
|
+
/** The framework-default inline-vs-offloaded byte cut, used when neither the field
|
|
264
|
+
marker nor the platform declares one. Retuning any level is safe: both arms of
|
|
265
|
+
the codec read back to identical bytes, so the threshold only decides how
|
|
266
|
+
*future* values are split — no wire change, no re-encoding, existing events
|
|
267
|
+
stay valid. */
|
|
268
|
+
let defaultThreshold = 8192
|
|
269
|
+
|
|
270
|
+
/** The per-field threshold an `@offload` field declares, if any — the top of the
|
|
271
|
+
precedence chain (`@offload({..., threshold})`). `None` when the field left it
|
|
272
|
+
unset, which defers to the platform default and then {!defaultThreshold}. */
|
|
273
|
+
let getThreshold = (schema: S.t<'a>): option<int> =>
|
|
274
|
+
switch Semantic.get(schema) {
|
|
275
|
+
| Some({id, payload: StoredIn({threshold})}) if id == Semantic.Id.offload => threshold
|
|
276
|
+
| _ => None
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
The effective threshold for a field, resolving the precedence chain most-specific
|
|
281
|
+
first: the per-field `@offload({threshold})` (read from the field's schema), then
|
|
282
|
+
the platform config default (`~platformDefault`, e.g. `MakeWithConfig`'s
|
|
283
|
+
`offloadThreshold`), then {!defaultThreshold}.
|
|
284
|
+
|
|
285
|
+
A client drives an offloadable field by reading its field schema, calling this to
|
|
286
|
+
get the cut, and passing the result as {!prepare}'s `~threshold`. `prepare` stays
|
|
287
|
+
threshold-explicit (it holds the *value* schema, not the field schema that carries
|
|
288
|
+
the marker), so this is the seam that turns the declaration into a number.
|
|
289
|
+
*/
|
|
290
|
+
let effectiveThreshold = (schema: S.t<'a>, ~platformDefault: option<int>=?, ()): int =>
|
|
291
|
+
switch getThreshold(schema) {
|
|
292
|
+
| Some(t) => t
|
|
293
|
+
| None => platformDefault->Option.getOr(defaultThreshold)
|
|
294
|
+
}
|
|
@@ -61,22 +61,24 @@ function schema(inner) {
|
|
|
61
61
|
]);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
function forStore(plugin, store, inner) {
|
|
64
|
+
function forStore(plugin, store, threshold, inner) {
|
|
65
65
|
return Semantic$Reventless.mark(schema(inner), Semantic$Reventless.Id.offload, {
|
|
66
66
|
TAG: "StoredIn",
|
|
67
67
|
_0: {
|
|
68
68
|
plugin: plugin,
|
|
69
|
-
store: store
|
|
69
|
+
store: store,
|
|
70
|
+
threshold: threshold
|
|
70
71
|
}
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
function optionSchema(plugin, store, inner) {
|
|
75
|
+
function optionSchema(plugin, store, threshold, inner) {
|
|
75
76
|
return Semantic$Reventless.mark(SuryResMjs.js_nullable(schema(inner)), Semantic$Reventless.Id.offload, {
|
|
76
77
|
TAG: "StoredIn",
|
|
77
78
|
_0: {
|
|
78
79
|
plugin: plugin,
|
|
79
|
-
store: store
|
|
80
|
+
store: store,
|
|
81
|
+
threshold: threshold
|
|
80
82
|
}
|
|
81
83
|
});
|
|
82
84
|
}
|
|
@@ -146,6 +148,30 @@ function getStore(schema) {
|
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
function getThreshold(schema) {
|
|
152
|
+
let match = Semantic$Reventless.get(schema);
|
|
153
|
+
if (match === undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let match$1 = match.payload;
|
|
157
|
+
if (typeof match$1 !== "object" || match$1.TAG === "ReferenceTo" || match.id !== Semantic$Reventless.Id.offload) {
|
|
158
|
+
return;
|
|
159
|
+
} else {
|
|
160
|
+
return match$1._0.threshold;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function effectiveThreshold(schema, platformDefault, param) {
|
|
165
|
+
let t = getThreshold(schema);
|
|
166
|
+
if (t !== undefined) {
|
|
167
|
+
return t;
|
|
168
|
+
} else {
|
|
169
|
+
return Stdlib_Option.getOr(platformDefault, 8192);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let defaultThreshold = 8192;
|
|
174
|
+
|
|
149
175
|
export {
|
|
150
176
|
offloadedRefSchema,
|
|
151
177
|
sentinelKey,
|
|
@@ -158,5 +184,8 @@ export {
|
|
|
158
184
|
resolve,
|
|
159
185
|
cachedFetch,
|
|
160
186
|
getStore,
|
|
187
|
+
defaultThreshold,
|
|
188
|
+
getThreshold,
|
|
189
|
+
effectiveThreshold,
|
|
161
190
|
}
|
|
162
191
|
/* offloadedRefSchema Not a pure module */
|
|
@@ -24,9 +24,12 @@ that can fail at runtime.
|
|
|
24
24
|
/** Which entity a reference field points to. */
|
|
25
25
|
type referenceTarget = {entity: string, plugin: option<string>}
|
|
26
26
|
|
|
27
|
-
/** Which object store a storage-ref field's value lives in. `plugin` is
|
|
28
|
-
when the store belongs to the declaring plugin, which is the common
|
|
29
|
-
|
|
27
|
+
/** Which object store a storage-ref / offload field's value lives in. `plugin` is
|
|
28
|
+
absent when the store belongs to the declaring plugin, which is the common
|
|
29
|
+
case. `threshold` is the per-field inline-vs-offloaded byte cut an `@offload`
|
|
30
|
+
field may declare (`None` for `@storageRef`, which is always a ref, and for
|
|
31
|
+
`@offload` fields that leave it to the platform default). */
|
|
32
|
+
type storeTarget = {plugin: option<string>, store: string, threshold: option<int>}
|
|
30
33
|
|
|
31
34
|
/** Per-semantic detail, for the semantics that carry any. */
|
|
32
35
|
type payload =
|
|
@@ -114,7 +114,7 @@ let forStore = (~plugin: option<string>=?, ~store: string): S.t<t> =>
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
)
|
|
117
|
-
->Semantic.mark(~id=Semantic.Id.storageRef, ~payload=StoredIn({plugin, store}))
|
|
117
|
+
->Semantic.mark(~id=Semantic.Id.storageRef, ~payload=StoredIn({plugin, store, threshold: None}))
|
|
118
118
|
|
|
119
119
|
/** The store a field's schema declares its refs live in, if any. */
|
|
120
120
|
let getStore = (schema: S.t<'a>): option<Semantic.storeTarget> =>
|