@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,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
The graft's spec surface, written rather than transcribed.
|
|
3
|
+
|
|
4
|
+
This trait grafts onto an **aggregate**, and that decides the split. An
|
|
5
|
+
aggregate's event stream is entity-scoped, so the trait's four facts and its two
|
|
6
|
+
`@noApi` report commands are real constructors a host splices — see
|
|
7
|
+
`AddressGeocoding.events` / `.reportCommands`. What is left for a scaffolder is
|
|
8
|
+
the half a spread cannot reach: the outbound slice, which is a whole component
|
|
9
|
+
the host does not have yet, and the arms that belong inside files the host
|
|
10
|
+
already owns.
|
|
11
|
+
|
|
12
|
+
So the shape here is the mirror of `Attachments_Scaffold`. That one writes
|
|
13
|
+
almost everything, because a DCB graft *is* a new slice. This one writes three
|
|
14
|
+
files and prints three patches, because most of an aggregate graft is either
|
|
15
|
+
spliced by the compiler or interleaved with the host's own state machine.
|
|
16
|
+
|
|
17
|
+
**Why this is code and not a template.** A `.res.tpl` compiles nowhere and is
|
|
18
|
+
checked by nothing. The fragments this module replaces had already drifted:
|
|
19
|
+
`OutboundTranslationSlice.res.tpl` declared the creation event as
|
|
20
|
+
`{{Created}}({ {{subject}}: string})`, and the one host that applied it carries
|
|
21
|
+
`Registered({email: string, address: string})` — a payload the template had no
|
|
22
|
+
way to express and no way to notice it was missing. Here it is `createdFields`,
|
|
23
|
+
and a host that needs it says so.
|
|
24
|
+
|
|
25
|
+
**What it does not do.** It never writes the host's policy. Which states an
|
|
26
|
+
address may be changed in, who may change it, what a host's own commands refuse
|
|
27
|
+
— those are `@transition` and `@authorize` over the host's own lifecycle, which
|
|
28
|
+
a trait cannot know. They are config when the host supplies them and absent
|
|
29
|
+
when it does not.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
The names a graft needs, and nothing else.
|
|
34
|
+
|
|
35
|
+
Everything here is a name or a literal spliced into a declaration. Nothing here
|
|
36
|
+
is control flow — that boundary is what keeps this a scaffolder rather than a
|
|
37
|
+
worse ReScript.
|
|
38
|
+
*/
|
|
39
|
+
@schema
|
|
40
|
+
type config = {
|
|
41
|
+
/** The host entity, capitalised: `"Customer"`. */
|
|
42
|
+
entity: string,
|
|
43
|
+
/** Its id field: `"customerId"`. */
|
|
44
|
+
entityId: string,
|
|
45
|
+
/** The host event that brings the entity into existence: `"Registered"`. It is
|
|
46
|
+
one of the slice's two triggers, because an entity created with an address
|
|
47
|
+
has never been geocoded. */
|
|
48
|
+
created: string,
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
What this host calls the thing being geocoded, lowercase: `"address"`.
|
|
52
|
+
|
|
53
|
+
Defaults to `"address"`, and that default is load-bearing rather than cosmetic:
|
|
54
|
+
`AddressGeocoding.events` fixes both the word and the type `string`, and a
|
|
55
|
+
spread cannot rename what it splices. A host that keeps the default gets the
|
|
56
|
+
aggregate half for one line; a host that calls it `"site"` or `"pickupPoint"`
|
|
57
|
+
gets the same rules and the same conformance suite, with the declarations
|
|
58
|
+
spelled out. The emitted patch says which of the two you are getting.
|
|
59
|
+
*/
|
|
60
|
+
subject?: string,
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
The creation event's payload *beyond* the subject, as declarations:
|
|
64
|
+
`["email: string"]`.
|
|
65
|
+
|
|
66
|
+
Carried because the slice re-declares the events it consumes, and a consumed
|
|
67
|
+
declaration that drops a field the host publishes is a decode this trait would
|
|
68
|
+
have caused and could not see.
|
|
69
|
+
*/
|
|
70
|
+
createdFields?: array<string>,
|
|
71
|
+
/** The same fields as fixture literals — `["email: \"alice@x.y\""]` — for the
|
|
72
|
+
conformance binding's histories. Omitted ⇒ omitted from the fixtures, which
|
|
73
|
+
only compiles when `createdFields` is omitted too. */
|
|
74
|
+
createdValues?: array<string>,
|
|
75
|
+
|
|
76
|
+
/** The slice component's name. Defaults to `Geocode<Entity><Subject>`. */
|
|
77
|
+
slice?: string,
|
|
78
|
+
/** The external box the Event Graph draws. Defaults to `"Geocoder"`; a host on
|
|
79
|
+
a named provider says so — `"AwsLocation"`. */
|
|
80
|
+
externalSystem?: string,
|
|
81
|
+
/** The read model the projection patch is addressed to. Defaults to
|
|
82
|
+
`<Entity>s`. */
|
|
83
|
+
view?: string,
|
|
84
|
+
|
|
85
|
+
/** Two subjects that differ, for the histories in which the address changes.
|
|
86
|
+
Defaulted when absent — they are test data, not a decision. */
|
|
87
|
+
subjectA?: string,
|
|
88
|
+
subjectB?: string,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A file the graft owns outright, written to disk. */
|
|
92
|
+
type file = {path: string, contents: string}
|
|
93
|
+
|
|
94
|
+
/** Arms for a file the host already owns. Printed for a human to place, never
|
|
95
|
+
written: inserting into an existing ordered `switch` is an AST operation, and
|
|
96
|
+
a text splice into the wrong arm is a bug the compiler cannot see. */
|
|
97
|
+
type patch = {into: string, at: string, contents: string}
|
|
98
|
+
|
|
99
|
+
type output = {files: array<file>, patches: array<patch>}
|
|
100
|
+
|
|
101
|
+
// ── The vocabulary, derived once ─────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
type names = {
|
|
104
|
+
/** `"address"` */
|
|
105
|
+
subject: string,
|
|
106
|
+
/** `"Address"` */
|
|
107
|
+
subjectCap: string,
|
|
108
|
+
/** `"GeocodeCustomerAddress"` */
|
|
109
|
+
slice: string,
|
|
110
|
+
/** `"Customers"` */
|
|
111
|
+
view: string,
|
|
112
|
+
/** `"AddressUpdated"`, `"AddressLocated"`, `"AddressUnresolvable"` */
|
|
113
|
+
updated: string,
|
|
114
|
+
located: string,
|
|
115
|
+
unresolvable: string,
|
|
116
|
+
/** `"UpdateAddress"`, `"SetAddressLocation"`, `"MarkAddressUnresolvable"` */
|
|
117
|
+
updateCmd: string,
|
|
118
|
+
suppliedPairCmd: string,
|
|
119
|
+
markUnresolvableCmd: string,
|
|
120
|
+
/** True when the host keeps the trait's own word and type, and can therefore
|
|
121
|
+
splice `AddressGeocoding.events` and `.reportCommands` instead of
|
|
122
|
+
declaring them. */
|
|
123
|
+
spreadable: bool,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let capitalise = s =>
|
|
127
|
+
s->String.charAt(0)->String.toUpperCase ++ s->String.slice(~start=1, ~end=s->String.length)
|
|
128
|
+
|
|
129
|
+
let namesOf = (c: config): names => {
|
|
130
|
+
let subject = c.subject->Option.getOr("address")
|
|
131
|
+
let subjectCap = capitalise(subject)
|
|
132
|
+
{
|
|
133
|
+
subject,
|
|
134
|
+
subjectCap,
|
|
135
|
+
slice: c.slice->Option.getOr("Geocode" ++ c.entity ++ subjectCap),
|
|
136
|
+
view: c.view->Option.getOr(c.entity ++ "s"),
|
|
137
|
+
updated: subjectCap ++ "Updated",
|
|
138
|
+
located: subjectCap ++ "Located",
|
|
139
|
+
unresolvable: subjectCap ++ "Unresolvable",
|
|
140
|
+
updateCmd: "Update" ++ subjectCap,
|
|
141
|
+
suppliedPairCmd: "Set" ++ subjectCap ++ "Location",
|
|
142
|
+
markUnresolvableCmd: "Mark" ++ subjectCap ++ "Unresolvable",
|
|
143
|
+
spreadable: subject == "address",
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let lines = (ls: array<string>) => ls->Array.join("\n")
|
|
148
|
+
|
|
149
|
+
/** The creation event's payload, subject last, as declarations or as literals.
|
|
150
|
+
One helper for both so the two can never fall out of step. */
|
|
151
|
+
let createdPayload = (c: config, ~n: names, ~values: bool) => {
|
|
152
|
+
let extra = values ? c.createdValues->Option.getOr([]) : c.createdFields->Option.getOr([])
|
|
153
|
+
let own = values ? n.subject : n.subject ++ ": string"
|
|
154
|
+
Array.concat(extra, [own])->Array.join(", ")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── The slice spec ───────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
let sliceSpec = (c: config): string => {
|
|
160
|
+
let n = namesOf(c)
|
|
161
|
+
lines([
|
|
162
|
+
`// ${n.slice}: turns the ${n.subject} into a point and reports back to ${c.entity} —`,
|
|
163
|
+
`// \`SetLocation\` when sure, \`${n.markUnresolvableCmd}\` when not. Neither is`,
|
|
164
|
+
`// callable from the API.`,
|
|
165
|
+
`//`,
|
|
166
|
+
`// Emitted by the address-geocoding trait. Everything below is this host's own`,
|
|
167
|
+
`// vocabulary, so it is ordinary source from here on — edit it freely.`,
|
|
168
|
+
``,
|
|
169
|
+
`@@reventless.spec`,
|
|
170
|
+
``,
|
|
171
|
+
`// Only the triggers. The event that carries ${n.subject} and point together is`,
|
|
172
|
+
`// deliberately absent: the slice stands down when a client geocoded for itself,`,
|
|
173
|
+
`// and the conformance suite asserts that this set is no wider than \`collect\`.`,
|
|
174
|
+
`@schema`,
|
|
175
|
+
`type consumedEvent =`,
|
|
176
|
+
` | ${c.created}({${createdPayload(c, ~n, ~values=false)}})`,
|
|
177
|
+
` | ${n.updated}({${n.subject}: string})`,
|
|
178
|
+
``,
|
|
179
|
+
`@schema`,
|
|
180
|
+
`type outboundItem = {${c.entityId}: string, ${n.subject}: string}`,
|
|
181
|
+
``,
|
|
182
|
+
`@schema`,
|
|
183
|
+
`type inboundCommand =`,
|
|
184
|
+
` | SetLocation({location: Reventless.GeoPoint.t, resolvedFrom: string})`,
|
|
185
|
+
` | ${n.markUnresolvableCmd}({${n.subject}: string, reason: string})`,
|
|
186
|
+
``,
|
|
187
|
+
`// Retries are for a geocoder that is down, not one that has answered.`,
|
|
188
|
+
`let maxRetries = 3`,
|
|
189
|
+
`let heartbeatInterval = 60`,
|
|
190
|
+
`let targetName = Some("${c.entity}")`,
|
|
191
|
+
``,
|
|
192
|
+
`// The ${c.entity} aggregate by its Spec.name; an outbound slice could once only`,
|
|
193
|
+
`// read its plugin's DCB event log.`,
|
|
194
|
+
`let sourceNames = ["${c.entity}"]`,
|
|
195
|
+
``,
|
|
196
|
+
`// Drawn as an external box outside this plugin in the Event Graph.`,
|
|
197
|
+
`let externalSystem = Some("${c.externalSystem->Option.getOr("Geocoder")}")`,
|
|
198
|
+
``,
|
|
199
|
+
`// The trait says what it reaches for; this host only names it. Spelling the`,
|
|
200
|
+
`// capability here instead would be the one part of the graft nothing checks —`,
|
|
201
|
+
`// an unprovisioned geocoder answers \`Unavailable\`, the retries run out, and`,
|
|
202
|
+
`// every ${n.subject} is recorded as permanently unresolvable with no error raised.`,
|
|
203
|
+
`let capabilityNeeds = TraitAddressGeocoding.AddressGeocoding.capabilityNeeds`,
|
|
204
|
+
`// The graft's own record of itself. Nothing else survives into a deployed`,
|
|
205
|
+
`// plugin — the dependency, the spread and the conformance binding are all`,
|
|
206
|
+
`// source-side — so without this a running estate cannot say where this`,
|
|
207
|
+
`// component came from.`,
|
|
208
|
+
`let traits = [TraitAddressGeocoding.AddressGeocoding.declaration]`,
|
|
209
|
+
``,
|
|
210
|
+
])
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── The slice body ───────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
let sliceTranslation = (c: config): string => {
|
|
216
|
+
let n = namesOf(c)
|
|
217
|
+
// Double-quoted rather than interpolated: these lines carry `${…}` and
|
|
218
|
+
// backticks that belong to the emitted code, not to this file.
|
|
219
|
+
let key = "`${sourceId}:${" ++ n.subject ++ "}`"
|
|
220
|
+
let collectArm = event =>
|
|
221
|
+
" | " ++
|
|
222
|
+
event ++
|
|
223
|
+
"({" ++
|
|
224
|
+
n.subject ++
|
|
225
|
+
"}) => [(" ++
|
|
226
|
+
key ++
|
|
227
|
+
", {" ++
|
|
228
|
+
c.entityId ++
|
|
229
|
+
": sourceId, " ++
|
|
230
|
+
n.subject ++
|
|
231
|
+
"})]"
|
|
232
|
+
lines([
|
|
233
|
+
`@@reventless.translation`,
|
|
234
|
+
``,
|
|
235
|
+
`// Keyed by entity *and* ${n.subject}: keying by entity alone would make a later`,
|
|
236
|
+
`// ${n.subject} change look like work already done. \`~sourceId\` is the entity id,`,
|
|
237
|
+
`// which an aggregate's event payload does not repeat.`,
|
|
238
|
+
`let collect = (event, ~sourceId) =>`,
|
|
239
|
+
` switch event {`,
|
|
240
|
+
collectArm(c.created),
|
|
241
|
+
collectArm(n.updated),
|
|
242
|
+
` }`,
|
|
243
|
+
``,
|
|
244
|
+
`// Asking the geocoder and reading its answer are the trait's — including the`,
|
|
245
|
+
`// confidence rule, and the rule that an outage is not a verdict. The two`,
|
|
246
|
+
`// commands the answer is reported through are this host's.`,
|
|
247
|
+
`module Geocode = TraitAddressGeocoding.AddressGeocoding_Translate`,
|
|
248
|
+
``,
|
|
249
|
+
`let translate = async (_id, item, ~capabilities: Reventless.Capabilities.t) =>`,
|
|
250
|
+
` (`,
|
|
251
|
+
` await Geocode.translate(`,
|
|
252
|
+
` ~text=item.${n.subject},`,
|
|
253
|
+
` ~capabilities,`,
|
|
254
|
+
` ~located=(~point, ~resolvedFrom) => SetLocation({location: point, resolvedFrom}),`,
|
|
255
|
+
` // A verdict for a human, not a coordinate.`,
|
|
256
|
+
` ~unresolvable=(~subject, ~reason) =>`,
|
|
257
|
+
` ${n.markUnresolvableCmd}({${n.subject}: subject, reason}),`,
|
|
258
|
+
` )`,
|
|
259
|
+
` )->Result.map(command => Some((item.${c.entityId}, command)))`,
|
|
260
|
+
``,
|
|
261
|
+
`// The budget is spent and the geocoder never answered. Recording the verdict`,
|
|
262
|
+
`// beats leaving the TODO pending forever — and it is why the capability must be`,
|
|
263
|
+
`// declared, since an unprovisioned geocoder reaches here every time.`,
|
|
264
|
+
`let onExhausted = (_id, item: outboundItem, ~lastError) =>`,
|
|
265
|
+
` Some((`,
|
|
266
|
+
` item.${c.entityId},`,
|
|
267
|
+
` ${n.markUnresolvableCmd}({`,
|
|
268
|
+
` ${n.subject}: item.${n.subject},`,
|
|
269
|
+
` reason: Geocode.exhaustedReason(lastError),`,
|
|
270
|
+
` }),`,
|
|
271
|
+
` ))`,
|
|
272
|
+
``,
|
|
273
|
+
])
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── The conformance binding ──────────────────────────────────────────────────
|
|
277
|
+
//
|
|
278
|
+
// Emitted whole and final. It is pure name-mapping — every line of it is already
|
|
279
|
+
// in the config — and it is the file a host would otherwise write by hand with
|
|
280
|
+
// nothing checking that the names line up.
|
|
281
|
+
|
|
282
|
+
let conformanceBinding = (c: config): string => {
|
|
283
|
+
let n = namesOf(c)
|
|
284
|
+
let subjectA = c.subjectA->Option.getOr("Stephansplatz 1, Vienna")
|
|
285
|
+
let subjectB = c.subjectB->Option.getOr("Kärntner Straße 1, Vienna")
|
|
286
|
+
let createdValue = `${c.created}({${createdPayload(c, ~n, ~values=true)}})`
|
|
287
|
+
lines([
|
|
288
|
+
`// The address-geocoding trait's conformance suite, bound to \`${c.entity}\`. The`,
|
|
289
|
+
`// graft rules are asserted by the trait; this file only says what the host calls`,
|
|
290
|
+
`// things. Emitted whole: every name here is one the graft already declared.`,
|
|
291
|
+
``,
|
|
292
|
+
`module Binding = {`,
|
|
293
|
+
` type subject = string`,
|
|
294
|
+
` let subjectText = s => s`,
|
|
295
|
+
` let subjectA = "${subjectA}"`,
|
|
296
|
+
` let subjectB = "${subjectB}"`,
|
|
297
|
+
` let posture = TraitAddressGeocoding.AddressGeocoding.WritesBack`,
|
|
298
|
+
``,
|
|
299
|
+
` module Spec = ${c.entity}`,
|
|
300
|
+
` module Behavior = ${c.entity}_Behavior`,
|
|
301
|
+
``,
|
|
302
|
+
` let created = ${n.subject} => [${c.entity}.${createdValue}]`,
|
|
303
|
+
` let subjectChanged = ${n.subject} => ${c.entity}.${n.updated}({${n.subject}: ${n.subject}})`,
|
|
304
|
+
` let located = (~point, ~resolvedFrom) =>`,
|
|
305
|
+
` ${c.entity}.LocationSet({location: point, resolvedFrom})`,
|
|
306
|
+
` let unresolvable = (~subject, ~reason) =>`,
|
|
307
|
+
` ${c.entity}.${n.unresolvable}({${n.subject}: subject, reason})`,
|
|
308
|
+
` let setLocation = (~point, ~resolvedFrom) =>`,
|
|
309
|
+
` ${c.entity}.SetLocation({location: point, resolvedFrom})`,
|
|
310
|
+
` let markUnresolvable = (~subject, ~reason) =>`,
|
|
311
|
+
` ${c.entity}.${n.markUnresolvableCmd}({${n.subject}: subject, reason})`,
|
|
312
|
+
``,
|
|
313
|
+
` module Slice = {`,
|
|
314
|
+
` include ${n.slice}`,
|
|
315
|
+
` let collect = ${n.slice}_Translation.collect`,
|
|
316
|
+
` }`,
|
|
317
|
+
` let translate = ${n.slice}_Translation.translate`,
|
|
318
|
+
` let item = (~entityId, ~subject) => {`,
|
|
319
|
+
` ${n.slice}.${c.entityId}: entityId,`,
|
|
320
|
+
` ${n.subject}: subject,`,
|
|
321
|
+
` }`,
|
|
322
|
+
` // Every event type the slice consumes appears here — the consumed set may`,
|
|
323
|
+
` // not be wider than its triggers, and the suite checks exactly that.`,
|
|
324
|
+
` let triggers = ${n.subject} => [`,
|
|
325
|
+
` ${n.slice}.${createdValue},`,
|
|
326
|
+
` ${n.slice}.${n.updated}({${n.subject}: ${n.subject}}),`,
|
|
327
|
+
` ]`,
|
|
328
|
+
` // The stand-down, as a real constructor rather than a type name: a`,
|
|
329
|
+
` // misspelled name would pass the assertion for the wrong reason.`,
|
|
330
|
+
` let standsDownOn = [`,
|
|
331
|
+
` ${c.entity}.${n.located}({${n.subject}: subjectA, location: {lat: 0.0, lng: 0.0}}),`,
|
|
332
|
+
` ]`,
|
|
333
|
+
` let isLocation = (cmd: ${n.slice}.inboundCommand) =>`,
|
|
334
|
+
` switch cmd {`,
|
|
335
|
+
` | SetLocation(_) => true`,
|
|
336
|
+
` | ${n.markUnresolvableCmd}(_) => false`,
|
|
337
|
+
` }`,
|
|
338
|
+
` let isVerdict = (cmd: ${n.slice}.inboundCommand) => !isLocation(cmd)`,
|
|
339
|
+
`}`,
|
|
340
|
+
``,
|
|
341
|
+
`module Conformance = TraitAddressGeocoding.AddressGeocoding_Conformance.Make(Binding)`,
|
|
342
|
+
``,
|
|
343
|
+
`Conformance.register()`,
|
|
344
|
+
``,
|
|
345
|
+
])
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── The aggregate patch ──────────────────────────────────────────────────────
|
|
349
|
+
//
|
|
350
|
+
// Printed, not written: the aggregate exists, and its command and event unions
|
|
351
|
+
// are the host's own. Which of the two forms below is printed is the one
|
|
352
|
+
// decision this module makes rather than transcribes — see `names.spreadable`.
|
|
353
|
+
|
|
354
|
+
// The four commands' arms for the host's `commandTransition`, which is where an
|
|
355
|
+
// aggregate graft's policy now lives.
|
|
356
|
+
//
|
|
357
|
+
// The two reports are answered outright — `Unrestricted` is a trait fact, not a
|
|
358
|
+
// host choice: a report must be legal in every state, or an answer landing after
|
|
359
|
+
// the entity moved on parks a TODO row forever. The two public commands are the
|
|
360
|
+
// host's call, so they are printed as a marked hole. That split is the same one
|
|
361
|
+
// the whole scaffold runs on, and it is why `@transition` could not serve here:
|
|
362
|
+
// an attribute cannot be attached to a constructor the host did not declare.
|
|
363
|
+
let transitionArms = (c: config): array<string> => {
|
|
364
|
+
let n = namesOf(c)
|
|
365
|
+
[
|
|
366
|
+
`// The switch is exhaustive, so these four arms are not optional — the`,
|
|
367
|
+
`// compiler will name whichever you leave out.`,
|
|
368
|
+
`| SetLocation(_) | ${n.markUnresolvableCmd}(_) => Unrestricted`,
|
|
369
|
+
`// TODO(graft): the states this host allows an ${n.subject} change in, as`,
|
|
370
|
+
`// constructors of the linked view's lifecycle enum — the compiler resolves`,
|
|
371
|
+
`// them, so a misspelling is a build error rather than a dead menu entry.`,
|
|
372
|
+
`//`,
|
|
373
|
+
`// type lifecycleState = ${n.view}.<lifecycle>`,
|
|
374
|
+
`//`,
|
|
375
|
+
`// | ${n.updateCmd}(_) | ${n.suppliedPairCmd}(_) => Guards([${n.view}.<State>])`,
|
|
376
|
+
]
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let aggregatePatch = (c: config): patch => {
|
|
380
|
+
let n = namesOf(c)
|
|
381
|
+
let commandArms = n.spreadable
|
|
382
|
+
? [
|
|
383
|
+
` // The two public ${n.subject} commands, spliced from the trait. Their`,
|
|
384
|
+
` // lifecycle guard lives in \`commandTransition\` below, not on the`,
|
|
385
|
+
` // constructors — which is what lets the trait own them at all.`,
|
|
386
|
+
` | ...TraitAddressGeocoding.AddressGeocoding.addressCommands`,
|
|
387
|
+
` // The two the slice reports through, likewise spliced. Both are \`@noApi\`,`,
|
|
388
|
+
` // and the exclusion is recorded on each member, so it survives the spread`,
|
|
389
|
+
` // and neither is published.`,
|
|
390
|
+
` | ...TraitAddressGeocoding.AddressGeocoding.reportCommands`,
|
|
391
|
+
]
|
|
392
|
+
: [
|
|
393
|
+
` // Spelled out rather than spliced: this host calls the subject`,
|
|
394
|
+
` // "${n.subject}", and a spread cannot rename what it splices.`,
|
|
395
|
+
` | ${n.updateCmd}({${n.subject}: string})`,
|
|
396
|
+
` | ${n.suppliedPairCmd}({${n.subject}: string, location: Reventless.GeoPoint.t})`,
|
|
397
|
+
` // Deliberately unguarded — legal in every state, \`Ok([])\` on a retired`,
|
|
398
|
+
` // entity, so an in-flight answer never parks a TODO row forever.`,
|
|
399
|
+
` | @noApi SetLocation({location: Reventless.GeoPoint.t, resolvedFrom: string})`,
|
|
400
|
+
` | @noApi ${n.markUnresolvableCmd}({${n.subject}: string, reason: string})`,
|
|
401
|
+
]
|
|
402
|
+
let eventArms = n.spreadable
|
|
403
|
+
? [
|
|
404
|
+
` // The graft's four facts, spliced from the trait: \`${n.updated}\`,`,
|
|
405
|
+
` // \`LocationSet\`, \`${n.located}\`, \`${n.unresolvable}\`. They are matched`,
|
|
406
|
+
` // unqualified in \`evolve\` and in the projections, and sury splices the schema`,
|
|
407
|
+
` // flat, so the wire format is what hand-written arms produced.`,
|
|
408
|
+
` | ...TraitAddressGeocoding.AddressGeocoding.events`,
|
|
409
|
+
]
|
|
410
|
+
: [
|
|
411
|
+
` | ${n.updated}({${n.subject}: string})`,
|
|
412
|
+
` // \`resolvedFrom\` is provenance, not the ${n.subject} of record; it is what`,
|
|
413
|
+
` // makes "is the pin still current?" decidable.`,
|
|
414
|
+
` | LocationSet({location: Reventless.GeoPoint.t, resolvedFrom: string})`,
|
|
415
|
+
` // Both halves from a client. Not \`${n.updated}\` + \`LocationSet\`: the slice`,
|
|
416
|
+
` // collects the former, and this event is not in its consumed set — the`,
|
|
417
|
+
` // stand-down.`,
|
|
418
|
+
` | ${n.located}({${n.subject}: string, location: Reventless.GeoPoint.t})`,
|
|
419
|
+
` // A fact, not an absence: \`location: None\` already means "not looked up yet".`,
|
|
420
|
+
` | ${n.unresolvable}({${n.subject}: string, reason: string})`,
|
|
421
|
+
]
|
|
422
|
+
{
|
|
423
|
+
into: `Aggregate/${c.entity}.res`,
|
|
424
|
+
at: n.spreadable
|
|
425
|
+
? `\`type command\` and \`type event\` — two spread lines and two commands`
|
|
426
|
+
: `\`type command\` and \`type event\` — spelled out, because the subject is not \`address\``,
|
|
427
|
+
contents: lines(
|
|
428
|
+
Array.concatMany(
|
|
429
|
+
[`// --- type command ------------------------------------------------------------`],
|
|
430
|
+
[
|
|
431
|
+
commandArms,
|
|
432
|
+
[``, `// --- type event --------------------------------------------------------------`],
|
|
433
|
+
eventArms,
|
|
434
|
+
[``, `// --- commandTransition -------------------------------------------------------`],
|
|
435
|
+
transitionArms(c),
|
|
436
|
+
],
|
|
437
|
+
),
|
|
438
|
+
),
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── The aggregate behavior patch ─────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
let behaviorPatch = (c: config): patch => {
|
|
445
|
+
let n = namesOf(c)
|
|
446
|
+
{
|
|
447
|
+
into: `Aggregate/${c.entity}_Behavior.res`,
|
|
448
|
+
at: `the state's fields, the \`evolve\` and \`decide\` switches, and two helpers`,
|
|
449
|
+
contents: lines([
|
|
450
|
+
`module Guards = TraitAddressGeocoding.AddressGeocoding_Guards`,
|
|
451
|
+
``,
|
|
452
|
+
`// --- state fields ------------------------------------------------------------`,
|
|
453
|
+
`// Two fields, because there are three states and one \`option\` holds two: never`,
|
|
454
|
+
`// asked, a point found, and the ${n.subject} tried and found wanting (a`,
|
|
455
|
+
`// resolved-from, no point). Host-owned rather than a trait record: an`,
|
|
456
|
+
`// aggregate's state is snapshotted, so a trait release that reshaped it would`,
|
|
457
|
+
`// be a migration. Invariant every arm preserves: \`locationResolvedFrom\` is`,
|
|
458
|
+
`// \`None\` or equal to \`${n.subject}\`.`,
|
|
459
|
+
` location: option<Reventless.GeoPoint.t>,`,
|
|
460
|
+
` locationResolvedFrom: option<string>,`,
|
|
461
|
+
``,
|
|
462
|
+
`// --- evolve arms -------------------------------------------------------------`,
|
|
463
|
+
` // A new ${n.subject} invalidates what was known; dropping both puts the row`,
|
|
464
|
+
` // back in front of the slice.`,
|
|
465
|
+
` | (Active(s), ${n.updated}({${n.subject}})) =>`,
|
|
466
|
+
` Active({...s, ${n.subject}, location: None, locationResolvedFrom: None})`,
|
|
467
|
+
` | (Active(s), LocationSet({location, resolvedFrom})) =>`,
|
|
468
|
+
` Active({...s, location: Some(location), locationResolvedFrom: Some(resolvedFrom)})`,
|
|
469
|
+
` // The caller supplied the pair, so there is nothing left to resolve.`,
|
|
470
|
+
` | (Active(s), ${n.located}({${n.subject}, location})) =>`,
|
|
471
|
+
` Active({`,
|
|
472
|
+
` ...s,`,
|
|
473
|
+
` ${n.subject},`,
|
|
474
|
+
` location: Some(location),`,
|
|
475
|
+
` locationResolvedFrom: Some(${n.subject}),`,
|
|
476
|
+
` })`,
|
|
477
|
+
` // Recording the ${n.subject} keeps the slice from handing it back for another round.`,
|
|
478
|
+
` | (Active(s), ${n.unresolvable}({${n.subject}})) =>`,
|
|
479
|
+
` Active({...s, location: None, locationResolvedFrom: Some(${n.subject})})`,
|
|
480
|
+
``,
|
|
481
|
+
`// --- the trait's view, and what it decides ------------------------------------`,
|
|
482
|
+
`// Built per call, because the inline record of \`Active\` cannot escape its`,
|
|
483
|
+
`// constructor.`,
|
|
484
|
+
`let resolution = (${n.subject}, location, locationResolvedFrom): Guards.resolution => {`,
|
|
485
|
+
` subject: ${n.subject},`,
|
|
486
|
+
` location,`,
|
|
487
|
+
` resolvedFrom: locationResolvedFrom,`,
|
|
488
|
+
`}`,
|
|
489
|
+
``,
|
|
490
|
+
`let appended = (verdict, event) =>`,
|
|
491
|
+
` switch verdict {`,
|
|
492
|
+
` | Guards.Append => Ok([event])`,
|
|
493
|
+
` | Guards.Ignore => Ok([])`,
|
|
494
|
+
` }`,
|
|
495
|
+
``,
|
|
496
|
+
`// --- decide arms -------------------------------------------------------------`,
|
|
497
|
+
` | (Active(s), ${n.updateCmd}({${n.subject}})) =>`,
|
|
498
|
+
` Guards.onSubjectUpdate(`,
|
|
499
|
+
` resolution(s.${n.subject}, s.location, s.locationResolvedFrom),`,
|
|
500
|
+
` ~subject=${n.subject},`,
|
|
501
|
+
` )->appended(${n.updated}({${n.subject}: ${n.subject}}))`,
|
|
502
|
+
``,
|
|
503
|
+
` | (Active(s), ${n.suppliedPairCmd}({${n.subject}, location})) =>`,
|
|
504
|
+
` Guards.onSuppliedPair(`,
|
|
505
|
+
` resolution(s.${n.subject}, s.location, s.locationResolvedFrom),`,
|
|
506
|
+
` ~subject=${n.subject},`,
|
|
507
|
+
` ~location,`,
|
|
508
|
+
` )->appended(${n.located}({${n.subject}, location}))`,
|
|
509
|
+
``,
|
|
510
|
+
` | (Active(s), SetLocation({location, resolvedFrom})) =>`,
|
|
511
|
+
` Guards.onLocationReport(`,
|
|
512
|
+
` resolution(s.${n.subject}, s.location, s.locationResolvedFrom),`,
|
|
513
|
+
` ~location,`,
|
|
514
|
+
` ~resolvedFrom,`,
|
|
515
|
+
` )->appended(LocationSet({location, resolvedFrom}))`,
|
|
516
|
+
``,
|
|
517
|
+
` | (Active(s), ${n.markUnresolvableCmd}({${n.subject}, reason})) =>`,
|
|
518
|
+
` Guards.onUnresolvableReport(`,
|
|
519
|
+
` resolution(s.${n.subject}, s.location, s.locationResolvedFrom),`,
|
|
520
|
+
` ~subject=${n.subject},`,
|
|
521
|
+
` )->appended(${n.unresolvable}({${n.subject}, reason}))`,
|
|
522
|
+
``,
|
|
523
|
+
` // TODO(graft): the two report commands must be legal in every state this`,
|
|
524
|
+
` // host has. On a retired entity, swallow rather than refuse — an in-flight`,
|
|
525
|
+
` // answer landing after retirement would otherwise park a TODO row forever.`,
|
|
526
|
+
` //`,
|
|
527
|
+
` // | (Retired(_), SetLocation(_)) => Ok([])`,
|
|
528
|
+
` // | (Retired(_), ${n.markUnresolvableCmd}(_)) => Ok([])`,
|
|
529
|
+
``,
|
|
530
|
+
]),
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ── The projection patch ─────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
let projectionPatch = (c: config): patch => {
|
|
537
|
+
let n = namesOf(c)
|
|
538
|
+
{
|
|
539
|
+
into: `ReadModelStream/${n.view}_Projections.res`,
|
|
540
|
+
at: `the projection's \`switch\`, and one field on \`${n.view}\`'s state`,
|
|
541
|
+
contents: lines([
|
|
542
|
+
`// On the view's state, one field:`,
|
|
543
|
+
`//`,
|
|
544
|
+
`// geolocation: Reventless.Geolocation.t,`,
|
|
545
|
+
`//`,
|
|
546
|
+
`// Three arms rather than \`option<GeoPoint.t>\`: \`Pending\` carries the ${n.subject}`,
|
|
547
|
+
`// asked about, so a stale answer is detectable from the row alone.`,
|
|
548
|
+
``,
|
|
549
|
+
`// On the creation arm's default state:`,
|
|
550
|
+
`// geolocation: Pending({requestedFor: ${n.subject}})`,
|
|
551
|
+
``,
|
|
552
|
+
`// A new ${n.subject} invalidates the pin: back to Pending for the new one.`,
|
|
553
|
+
`| ${n.updated}({${n.subject}}) =>`,
|
|
554
|
+
` Update(id, state => {`,
|
|
555
|
+
` ...state,`,
|
|
556
|
+
` ${n.subject},`,
|
|
557
|
+
` geolocation: Pending({requestedFor: ${n.subject}}),`,
|
|
558
|
+
` })`,
|
|
559
|
+
`| LocationSet({location}) =>`,
|
|
560
|
+
` Update(id, state => {...state, geolocation: Located({point: location})})`,
|
|
561
|
+
`// The client supplied the pair, so no geocode is owed.`,
|
|
562
|
+
`| ${n.located}({${n.subject}, location}) =>`,
|
|
563
|
+
` Update(id, state => {...state, ${n.subject}, geolocation: Located({point: location})})`,
|
|
564
|
+
`| ${n.unresolvable}({reason: why}) =>`,
|
|
565
|
+
` Update(id, state => {...state, geolocation: Unresolvable({reason: why})})`,
|
|
566
|
+
]),
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
Emit a graft.
|
|
572
|
+
|
|
573
|
+
Three files written, three patches printed — the mirror of the attachments
|
|
574
|
+
scaffold, and the mirror for a reason: an aggregate host already owns the files
|
|
575
|
+
most of this graft lands in, and the compiler splices the rest.
|
|
576
|
+
|
|
577
|
+
The files are the host's from the moment they land — nothing regenerates them,
|
|
578
|
+
and nothing compares against them later.
|
|
579
|
+
*/
|
|
580
|
+
let emit = (~config: config, ~into: string, ~tests: string): output => {
|
|
581
|
+
let n = namesOf(config)
|
|
582
|
+
{
|
|
583
|
+
files: [
|
|
584
|
+
{
|
|
585
|
+
path: `${into}/OutboundTranslationSlice/${n.slice}.res`,
|
|
586
|
+
contents: sliceSpec(config),
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
path: `${into}/OutboundTranslationSlice/${n.slice}_Translation.res`,
|
|
590
|
+
contents: sliceTranslation(config),
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
path: `${tests}/AddressGeocodingConformance_GWT.res`,
|
|
594
|
+
contents: conformanceBinding(config),
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
patches: [aggregatePatch(config), behaviorPatch(config), projectionPatch(config)],
|
|
598
|
+
}
|
|
599
|
+
}
|