@reventlessdev/trait-address-geocoding 1.0.0-alpha.1

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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ The conformance suite, run by a host against its own graft. `Make(Binding).register()`
3
+ inside a Jest test file registers one `describe` block; every assertion is written
4
+ over the binding, never over a host's constructors.
5
+ */
6
+
7
+ // Warning 23 flags the spread in `geocoder` as redundant today; it is there for
8
+ // the day `Capabilities.t` grows a field.
9
+ @@warning("-23")
10
+
11
+ module Outcome = ReventlessGwt.Outcome
12
+
13
+ /** The suite's title, composed once and read twice: the suite registers it,
14
+ and `certify-trait` computes the same string to find the run's assertions in
15
+ a test report. Exported rather than inlined so neither side parses the
16
+ other's prose. */
17
+ let suiteName = (host: string) => `${host} conforms to the address-geocoding trait`
18
+
19
+ module Make = (B: AddressGeocoding.Binding) => {
20
+ module A = ReventlessGwt.Behavior_GWT.MakeFromAggregate(B.Spec, B.Behavior)
21
+ module S = ReventlessGwt.OutboundTranslation_GWT.Make(B.Slice)
22
+
23
+ let vienna: Reventless.GeoPoint.t = {lat: 48.2082, lng: 16.3738}
24
+ let bruges: Reventless.GeoPoint.t = {lat: 51.2093, lng: 3.2247}
25
+ let entityId = "entity-1"
26
+ let todoKey = subject => `${entityId}:${B.subjectText(subject)}`
27
+
28
+ let geocoder = (answer): Reventless.Capabilities.t => {
29
+ ...Reventless.Capabilities.none,
30
+ geocode: (~text as _) => Promise.resolve(answer),
31
+ }
32
+ let translateWith = answer => (id, item) => B.translate(id, item, ~capabilities=geocoder(answer))
33
+
34
+ let describeCommand = cmd =>
35
+ cmd->Reventless.Message.encode(B.Slice.inboundCommandSchema)->JSON.stringify
36
+
37
+ let thenReports = async (pending: promise<S.attempt>, ~expected, predicate) => {
38
+ let {result, _} = await pending
39
+ switch result {
40
+ | Ok(Some((_, cmd))) if predicate(cmd) => Outcome.pass
41
+ | Ok(Some((_, cmd))) =>
42
+ Outcome.fail(TranslateError({expected, actual: Some(describeCommand(cmd))}))
43
+ | Ok(None) => Outcome.fail(TranslateError({expected, actual: Some("no command")}))
44
+ | Error(why) => Outcome.fail(TranslateError({expected, actual: Some(why)}))
45
+ }
46
+ }
47
+
48
+ let sorted = names => names->Array.toSorted(String.compare)
49
+ let strings = names => names->Array.map(JSON.Encode.string)
50
+
51
+ let consumedSetMatchesTriggers = () => {
52
+ let consumed = Reventless.DcbDecode.makeDecoder(B.Slice.consumedEventSchema).eventTypes->sorted
53
+ let triggers =
54
+ B.triggers(B.subjectA)
55
+ ->Array.map(e =>
56
+ e->Reventless.Message.encode(B.Slice.consumedEventSchema)->Reventless.Message.variantNameOfJson
57
+ )
58
+ ->sorted
59
+ // Encoded through the aggregate's own schema, so a stand-down event is a
60
+ // constructor the compiler checked rather than a name that merely looks like
61
+ // one. Compared on variant names because that is what a consumed set holds.
62
+ let standsDownOn =
63
+ B.standsDownOn->Array.map(e =>
64
+ e->Reventless.Message.encode(B.Spec.eventSchema)->Reventless.Message.variantNameOfJson
65
+ )
66
+ let widened = consumed->Array.some(t => standsDownOn->Array.includes(t))
67
+ if consumed == triggers && !widened {
68
+ Outcome.pass
69
+ } else {
70
+ Outcome.fail(
71
+ StateMismatch({
72
+ key: "consumedEvent",
73
+ expected: Some(JSON.Encode.array(strings(triggers))),
74
+ actual: Some(JSON.Encode.array(strings(consumed))),
75
+ }),
76
+ )
77
+ }
78
+ }
79
+
80
+ let register = () =>
81
+ A.describe(suiteName(B.Spec.name), () => {
82
+ switch B.posture {
83
+ | Observes =>
84
+ A.test("posture: Observes is not supported by this runner", () =>
85
+ Outcome.fail(
86
+ Throw({error: "the runner checks a write-back graft; bind one, or wait", stack: ""}),
87
+ )
88
+ )
89
+ | WritesBack => ()
90
+ }
91
+
92
+ // Aggregate: the graft rules.
93
+ A.test("an answer for the current subject is applied", () =>
94
+ A.givenEvents(B.created(B.subjectA))
95
+ ->A.whenCmd(B.setLocation(~point=vienna, ~resolvedFrom=B.subjectA))
96
+ ->A.thenEvent(B.located(~point=vienna, ~resolvedFrom=B.subjectA))
97
+ )
98
+
99
+ A.test("a verdict for the current subject is recorded", () =>
100
+ A.givenEvents(B.created(B.subjectA))
101
+ ->A.whenCmd(B.markUnresolvable(~subject=B.subjectA, ~reason="no match"))
102
+ ->A.thenEvent(B.unresolvable(~subject=B.subjectA, ~reason="no match"))
103
+ )
104
+
105
+ A.test("an answer for a superseded subject is dropped as stale", () =>
106
+ A.givenEvents(Array.concat(B.created(B.subjectA), [B.subjectChanged(B.subjectB)]))
107
+ ->A.whenCmd(B.setLocation(~point=vienna, ~resolvedFrom=B.subjectA))
108
+ ->A.thenNoEvent
109
+ )
110
+
111
+ A.test("a verdict for a superseded subject is dropped as stale", () =>
112
+ A.givenEvents(Array.concat(B.created(B.subjectA), [B.subjectChanged(B.subjectB)]))
113
+ ->A.whenCmd(B.markUnresolvable(~subject=B.subjectA, ~reason="no match"))
114
+ ->A.thenNoEvent
115
+ )
116
+
117
+ A.test("a redelivered answer is a no-op", () =>
118
+ A.givenEvents(
119
+ Array.concat(B.created(B.subjectA), [B.located(~point=vienna, ~resolvedFrom=B.subjectA)]),
120
+ )
121
+ ->A.whenCmd(B.setLocation(~point=vienna, ~resolvedFrom=B.subjectA))
122
+ ->A.thenNoEvent
123
+ )
124
+
125
+ A.test("a redelivered verdict is a no-op", () =>
126
+ A.givenEvents(
127
+ Array.concat(B.created(B.subjectA), [B.unresolvable(~subject=B.subjectA, ~reason="no match")]),
128
+ )
129
+ ->A.whenCmd(B.markUnresolvable(~subject=B.subjectA, ~reason="no match"))
130
+ ->A.thenNoEvent
131
+ )
132
+
133
+ A.test("a subject change reopens the question", () =>
134
+ A.givenEvents(
135
+ Array.concat(
136
+ B.created(B.subjectA),
137
+ [B.located(~point=vienna, ~resolvedFrom=B.subjectA), B.subjectChanged(B.subjectB)],
138
+ ),
139
+ )
140
+ ->A.whenCmd(B.setLocation(~point=bruges, ~resolvedFrom=B.subjectB))
141
+ ->A.thenEvent(B.located(~point=bruges, ~resolvedFrom=B.subjectB))
142
+ )
143
+
144
+ // Slice: the binding surface.
145
+ A.test("the slice consumes exactly its triggers and stands down on a supplied pair", () =>
146
+ consumedSetMatchesTriggers()
147
+ )
148
+
149
+ B.triggers(B.subjectA)->Array.forEachWithIndex((trigger, i) =>
150
+ S.testSync(`trigger ${Int.toString(i + 1)} queues one TODO keyed by entity and subject`, () =>
151
+ S.givenEvent(trigger)
152
+ ->S.whenCollect(~sourceId=entityId)
153
+ ->S.thenTodos([(todoKey(B.subjectA), B.item(~entityId, ~subject=B.subjectA))])
154
+ )
155
+ )
156
+
157
+ S.test("a confident answer reports the point", () =>
158
+ S.givenTodo(todoKey(B.subjectA), B.item(~entityId, ~subject=B.subjectA))
159
+ ->S.whenTranslateMocked(
160
+ translateWith(
161
+ Ok([
162
+ {
163
+ Reventless.Geocoding.label: B.subjectText(B.subjectA),
164
+ point: vienna,
165
+ relevance: Some(0.995),
166
+ },
167
+ ]),
168
+ ),
169
+ )
170
+ ->thenReports(~expected="a location", B.isLocation)
171
+ )
172
+
173
+ S.test("a no-match completes the TODO with a verdict", () =>
174
+ S.givenTodo(todoKey(B.subjectA), B.item(~entityId, ~subject=B.subjectA))
175
+ ->S.whenTranslateMocked(translateWith(Error(Reventless.Geocoding.NoMatch)))
176
+ ->thenReports(~expected="a verdict", B.isVerdict)
177
+ )
178
+
179
+ S.test("an outage leaves the TODO pending", () =>
180
+ S.givenTodo(todoKey(B.subjectA), B.item(~entityId, ~subject=B.subjectA))
181
+ ->S.whenTranslateMocked(translateWith(Error(Reventless.Geocoding.Unavailable("502"))))
182
+ ->S.thenTodoStatus(todoKey(B.subjectA), #Pending)
183
+ )
184
+ })
185
+ }
@@ -0,0 +1,156 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
4
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
5
+ import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
6
+ import * as DcbDecode$Reventless from "@reventlessdev/reventless-spec/src/components/DcbDecode.res.mjs";
7
+ import * as Outcome$ReventlessGwt from "@reventlessdev/reventless-gwt/src/Outcome.res.mjs";
8
+ import * as Capabilities$Reventless from "@reventlessdev/reventless-spec/src/semantic/Capabilities.res.mjs";
9
+ import * as Behavior_GWT$ReventlessGwt from "@reventlessdev/reventless-gwt/src/Behavior_GWT.res.mjs";
10
+ import * as OutboundTranslation_GWT$ReventlessGwt from "@reventlessdev/reventless-gwt/src/OutboundTranslation_GWT.res.mjs";
11
+
12
+ function suiteName(host) {
13
+ return host + ` conforms to the address-geocoding trait`;
14
+ }
15
+
16
+ function Make(B) {
17
+ let A = Behavior_GWT$ReventlessGwt.MakeFromAggregate(B.Spec)(B.Behavior);
18
+ let S = OutboundTranslation_GWT$ReventlessGwt.Make(B.Slice);
19
+ let vienna = {
20
+ lat: 48.2082,
21
+ lng: 16.3738
22
+ };
23
+ let bruges = {
24
+ lat: 51.2093,
25
+ lng: 3.2247
26
+ };
27
+ let entityId = "entity-1";
28
+ let todoKey = subject => entityId + `:` + B.subjectText(subject);
29
+ let geocoder = answer => ({
30
+ geocode: param => Promise.resolve(answer),
31
+ messaging: Capabilities$Reventless.none.messaging
32
+ });
33
+ let translateWith = answer => ((id, item) => B.translate(id, item, geocoder(answer)));
34
+ let describeCommand = cmd => JSON.stringify(Message$Reventless.encode(cmd, B.Slice.inboundCommandSchema));
35
+ let thenReports = async (pending, expected, predicate) => {
36
+ let match = await pending;
37
+ let result = match.result;
38
+ if (result.TAG !== "Ok") {
39
+ return Outcome$ReventlessGwt.fail({
40
+ TAG: "TranslateError",
41
+ expected: expected,
42
+ actual: result._0
43
+ });
44
+ }
45
+ let match$1 = result._0;
46
+ if (match$1 === undefined) {
47
+ return Outcome$ReventlessGwt.fail({
48
+ TAG: "TranslateError",
49
+ expected: expected,
50
+ actual: "no command"
51
+ });
52
+ }
53
+ let cmd = match$1[1];
54
+ if (predicate(cmd)) {
55
+ return Outcome$ReventlessGwt.pass;
56
+ } else {
57
+ return Outcome$ReventlessGwt.fail({
58
+ TAG: "TranslateError",
59
+ expected: expected,
60
+ actual: describeCommand(cmd)
61
+ });
62
+ }
63
+ };
64
+ let sorted = names => names.toSorted(Primitive_string.compare);
65
+ let strings = names => names.map(prim => prim);
66
+ let consumedSetMatchesTriggers = () => {
67
+ let consumed = sorted(DcbDecode$Reventless.makeDecoder(B.Slice.consumedEventSchema).eventTypes);
68
+ let triggers = sorted(B.triggers(B.subjectA).map(e => Message$Reventless.variantNameOfJson(Message$Reventless.encode(e, B.Slice.consumedEventSchema))));
69
+ let standsDownOn = B.standsDownOn.map(e => Message$Reventless.variantNameOfJson(Message$Reventless.encode(e, B.Spec.eventSchema)));
70
+ let widened = consumed.some(t => standsDownOn.includes(t));
71
+ if (Primitive_object.equal(consumed, triggers) && !widened) {
72
+ return Outcome$ReventlessGwt.pass;
73
+ } else {
74
+ return Outcome$ReventlessGwt.fail({
75
+ TAG: "StateMismatch",
76
+ key: "consumedEvent",
77
+ expected: triggers.map(prim => prim),
78
+ actual: consumed.map(prim => prim)
79
+ });
80
+ }
81
+ };
82
+ let register = () => A.describe(suiteName(B.Spec.name), () => {
83
+ if (B.posture !== "WritesBack") {
84
+ A.test("posture: Observes is not supported by this runner", () => Outcome$ReventlessGwt.fail({
85
+ TAG: "Throw",
86
+ error: "the runner checks a write-back graft; bind one, or wait",
87
+ stack: ""
88
+ }));
89
+ }
90
+ A.test("an answer for the current subject is applied", () => A.thenEvent(A.whenCmd(A.givenEvents(B.created(B.subjectA)), B.setLocation(vienna, B.subjectA)), B.located(vienna, B.subjectA)));
91
+ A.test("a verdict for the current subject is recorded", () => A.thenEvent(A.whenCmd(A.givenEvents(B.created(B.subjectA)), B.markUnresolvable(B.subjectA, "no match")), B.unresolvable(B.subjectA, "no match")));
92
+ A.test("an answer for a superseded subject is dropped as stale", () => A.thenNoEvent(A.whenCmd(A.givenEvents(B.created(B.subjectA).concat([B.subjectChanged(B.subjectB)])), B.setLocation(vienna, B.subjectA))));
93
+ A.test("a verdict for a superseded subject is dropped as stale", () => A.thenNoEvent(A.whenCmd(A.givenEvents(B.created(B.subjectA).concat([B.subjectChanged(B.subjectB)])), B.markUnresolvable(B.subjectA, "no match"))));
94
+ A.test("a redelivered answer is a no-op", () => A.thenNoEvent(A.whenCmd(A.givenEvents(B.created(B.subjectA).concat([B.located(vienna, B.subjectA)])), B.setLocation(vienna, B.subjectA))));
95
+ A.test("a redelivered verdict is a no-op", () => A.thenNoEvent(A.whenCmd(A.givenEvents(B.created(B.subjectA).concat([B.unresolvable(B.subjectA, "no match")])), B.markUnresolvable(B.subjectA, "no match"))));
96
+ A.test("a subject change reopens the question", () => A.thenEvent(A.whenCmd(A.givenEvents(B.created(B.subjectA).concat([
97
+ B.located(vienna, B.subjectA),
98
+ B.subjectChanged(B.subjectB)
99
+ ])), B.setLocation(bruges, B.subjectB)), B.located(bruges, B.subjectB)));
100
+ A.test("the slice consumes exactly its triggers and stands down on a supplied pair", () => consumedSetMatchesTriggers());
101
+ B.triggers(B.subjectA).forEach((trigger, i) => S.testSync(`trigger ` + (i + 1 | 0).toString() + ` queues one TODO keyed by entity and subject`, () => S.thenTodos(S.whenCollect(S.givenEvent(trigger), entityId), [[
102
+ todoKey(B.subjectA),
103
+ B.item(entityId, B.subjectA)
104
+ ]])));
105
+ S.test("a confident answer reports the point", undefined, () => thenReports(S.whenTranslateMocked(S.givenTodo(todoKey(B.subjectA), B.item(entityId, B.subjectA)), translateWith({
106
+ TAG: "Ok",
107
+ _0: [{
108
+ label: B.subjectText(B.subjectA),
109
+ point: vienna,
110
+ relevance: 0.995
111
+ }]
112
+ })), "a location", B.isLocation));
113
+ S.test("a no-match completes the TODO with a verdict", undefined, () => thenReports(S.whenTranslateMocked(S.givenTodo(todoKey(B.subjectA), B.item(entityId, B.subjectA)), (id, item) => B.translate(id, item, {
114
+ geocode: param => Promise.resolve({
115
+ TAG: "Error",
116
+ _0: "NoMatch"
117
+ }),
118
+ messaging: Capabilities$Reventless.none.messaging
119
+ })), "a verdict", B.isVerdict));
120
+ S.test("an outage leaves the TODO pending", undefined, () => S.thenTodoStatus(S.whenTranslateMocked(S.givenTodo(todoKey(B.subjectA), B.item(entityId, B.subjectA)), (id, item) => B.translate(id, item, {
121
+ geocode: param => Promise.resolve({
122
+ TAG: "Error",
123
+ _0: {
124
+ TAG: "Unavailable",
125
+ _0: "502"
126
+ }
127
+ }),
128
+ messaging: Capabilities$Reventless.none.messaging
129
+ })), todoKey(B.subjectA), "Pending"));
130
+ });
131
+ return {
132
+ A: A,
133
+ S: S,
134
+ vienna: vienna,
135
+ bruges: bruges,
136
+ entityId: entityId,
137
+ todoKey: todoKey,
138
+ geocoder: geocoder,
139
+ translateWith: translateWith,
140
+ describeCommand: describeCommand,
141
+ thenReports: thenReports,
142
+ sorted: sorted,
143
+ strings: strings,
144
+ consumedSetMatchesTriggers: consumedSetMatchesTriggers,
145
+ register: register
146
+ };
147
+ }
148
+
149
+ let Outcome;
150
+
151
+ export {
152
+ Outcome,
153
+ suiteName,
154
+ Make,
155
+ }
156
+ /* Message-Reventless Not a pure module */
@@ -0,0 +1,58 @@
1
+ /**
2
+ The graft's decision rules, compiled once and called by every host: an answer for
3
+ a subject the entity has moved off is stale, a redelivered answer is a no-op, and
4
+ a verdict already recorded for the current subject is not recorded twice.
5
+
6
+ The host keeps its own state — an aggregate's is snapshotted, so a trait-owned
7
+ record in it would make every release that reshapes it a migration — and builds a
8
+ `resolution` per call from the fields it already holds. What comes back is a
9
+ verdict on one command, which the host names in its own event.
10
+ */
11
+
12
+ /** The three fields the rules read, as the host holds them. Its invariant, which
13
+ every host arm must preserve: `resolvedFrom` is `None` or equal to `subject`. */
14
+ type resolution = {
15
+ subject: string,
16
+ location: option<Reventless.GeoPoint.t>,
17
+ resolvedFrom: option<string>,
18
+ }
19
+
20
+ type verdict = Append | Ignore
21
+
22
+ /** A subject the entity already has changes nothing; a different one reopens the
23
+ question, because the host's fold drops what was resolved for the old one. */
24
+ let onSubjectUpdate = (r, ~subject) => subject == r.subject ? Ignore : Append
25
+
26
+ /**
27
+ A client supplying both halves. Idempotent only when *both* already match: the
28
+ same subject with a different point is a pin correction, and swallowing it would
29
+ lose the one edit a human came to make.
30
+ */
31
+ let onSuppliedPair = (r, ~subject, ~location) =>
32
+ subject == r.subject && r.location == Some(location) ? Ignore : Append
33
+
34
+ /**
35
+ A point reported by the geocoder. Stale when it answers a subject since changed —
36
+ applying it would pin the new subject at the old one's point with nothing to say
37
+ the two disagree. Redelivery matters because commands arrive at least once and the
38
+ slice re-publishes on every heartbeat until its TODO clears.
39
+ */
40
+ let onLocationReport = (r, ~location, ~resolvedFrom) =>
41
+ if resolvedFrom != r.subject {
42
+ Ignore
43
+ } else if r.location == Some(location) && r.resolvedFrom == Some(resolvedFrom) {
44
+ Ignore
45
+ } else {
46
+ Append
47
+ }
48
+
49
+ /** The same two guards for the verdict: `resolvedFrom` already naming the subject
50
+ is the record that it was tried and found wanting. */
51
+ let onUnresolvableReport = (r, ~subject) =>
52
+ if subject != r.subject {
53
+ Ignore
54
+ } else if r.resolvedFrom == Some(subject) {
55
+ Ignore
56
+ } else {
57
+ Append
58
+ }
@@ -0,0 +1,43 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
4
+
5
+ function onSubjectUpdate(r, subject) {
6
+ if (subject === r.subject) {
7
+ return "Ignore";
8
+ } else {
9
+ return "Append";
10
+ }
11
+ }
12
+
13
+ function onSuppliedPair(r, subject, location) {
14
+ if (subject === r.subject && Primitive_object.equal(r.location, location)) {
15
+ return "Ignore";
16
+ } else {
17
+ return "Append";
18
+ }
19
+ }
20
+
21
+ function onLocationReport(r, location, resolvedFrom) {
22
+ if (resolvedFrom !== r.subject || Primitive_object.equal(r.location, location) && Primitive_object.equal(r.resolvedFrom, resolvedFrom)) {
23
+ return "Ignore";
24
+ } else {
25
+ return "Append";
26
+ }
27
+ }
28
+
29
+ function onUnresolvableReport(r, subject) {
30
+ if (subject !== r.subject || Primitive_object.equal(r.resolvedFrom, subject)) {
31
+ return "Ignore";
32
+ } else {
33
+ return "Append";
34
+ }
35
+ }
36
+
37
+ export {
38
+ onSubjectUpdate,
39
+ onSuppliedPair,
40
+ onLocationReport,
41
+ onUnresolvableReport,
42
+ }
43
+ /* No side effect */