@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.
- package/LICENSE +202 -0
- package/README.md +92 -0
- package/package.json +42 -0
- package/rescript.json +29 -0
- package/src/AddressGeocoding.res +177 -0
- package/src/AddressGeocoding.res.mjs +77 -0
- package/src/AddressGeocoding_Conformance.res +185 -0
- package/src/AddressGeocoding_Conformance.res.mjs +156 -0
- package/src/AddressGeocoding_Guards.res +58 -0
- package/src/AddressGeocoding_Guards.res.mjs +43 -0
- package/src/AddressGeocoding_Scaffold.res +599 -0
- package/src/AddressGeocoding_Scaffold.res.mjs +452 -0
- package/src/AddressGeocoding_Translate.res +40 -0
- package/src/AddressGeocoding_Translate.res.mjs +54 -0
|
@@ -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 */
|