@reventlessdev/trait-notification 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,814 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+
6
+ let configSchema = Sury.$schema(s => ({
7
+ chapter: s.m(Sury.string),
8
+ noun: s.m(Sury.string),
9
+ categories: s.m(Sury.array(Sury.string)),
10
+ transactional: s.m(Sury.array(Sury.string)),
11
+ contactSource: s.m(Sury.string),
12
+ contactEvents: s.m(Sury.array(Sury.string)),
13
+ contactField: s.m(Sury.string),
14
+ occurrence: s.m(Sury.string),
15
+ occurrenceId: s.m(Sury.string),
16
+ occurrenceRecipient: s.m(Sury.string),
17
+ occurrenceCategory: s.m(Sury.string),
18
+ authorize: s.m(Sury.$option(Sury.string)),
19
+ addressA: s.m(Sury.$option(Sury.string)),
20
+ addressB: s.m(Sury.$option(Sury.string))
21
+ }));
22
+
23
+ function namesOf(c) {
24
+ let n = c.noun;
25
+ return {
26
+ slice: n + "Preferences",
27
+ send: "Send" + n + "Notification",
28
+ relay: "Announce" + n + "Contact",
29
+ intake: n + "NotificationIntake",
30
+ deliveries: n + "Deliveries",
31
+ subscriptions: n + "Subscriptions",
32
+ announceCmd: "Announce" + n,
33
+ subscribeCmd: "Subscribe",
34
+ unsubscribeCmd: "Unsubscribe",
35
+ requestCmd: "RequestNotification",
36
+ recordCmd: "RecordDelivery",
37
+ recordFailureCmd: "RecordDeliveryFailure",
38
+ announced: n + "Announced",
39
+ subscribed: "NotificationSubscribed",
40
+ unsubscribed: "NotificationUnsubscribed",
41
+ requested: "NotificationRequested",
42
+ suppressed: "NotificationSuppressed",
43
+ undeliverable: "NotificationUndeliverable",
44
+ delivered: "NotificationDelivered",
45
+ failed: "NotificationFailed",
46
+ unknownError: n + "Unknown",
47
+ recipientId: n.slice(0, 1).toLowerCase() + n.slice(1, n.length) + "Id"
48
+ };
49
+ }
50
+
51
+ function lines(ls) {
52
+ return ls.join("\n");
53
+ }
54
+
55
+ function addressA(c) {
56
+ return Stdlib_Option.getOr(c.addressA, "buyer@example.com");
57
+ }
58
+
59
+ function addressB(c) {
60
+ return Stdlib_Option.getOr(c.addressB, "new@example.com");
61
+ }
62
+
63
+ function authorizeOn(c) {
64
+ let a = c.authorize;
65
+ if (a !== undefined) {
66
+ return ` | @authorize(` + a + `)\n `;
67
+ } else {
68
+ return " | ";
69
+ }
70
+ }
71
+
72
+ function categoryArms(c) {
73
+ return c.categories.map(cat => ` | ` + cat);
74
+ }
75
+
76
+ function sliceSpec(c) {
77
+ let n = namesOf(c);
78
+ let id = n.recipientId;
79
+ let auth = authorizeOn(c);
80
+ return [
81
+ [
82
+ `// ` + n.slice + ` StateChangeSlice: the notification competency's one decision`,
83
+ `// point — where a ` + c.noun.toLowerCase() + ` is reachable, which kinds they want on which`,
84
+ `// channel, and whether a request to notify them becomes a message.`,
85
+ `//`,
86
+ `// A graft of the Notification trait; the rules are the trait's and are asserted`,
87
+ `// by its conformance suite, bound in the tests.`,
88
+ `//`,
89
+ `// Emitted by the trait. Everything below is this host's own vocabulary, so it is`,
90
+ `// ordinary source from here on — edit it freely.`,
91
+ ``,
92
+ `@@reventless.spec`,
93
+ ``,
94
+ `// The kinds a ` + c.noun.toLowerCase() + ` subscribes to. Not raw event names: a settings`,
95
+ `// screen listing event types is a log dump, and these are choices a person can`,
96
+ `// hold an opinion about.`,
97
+ `@schema`,
98
+ `type category =`
99
+ ],
100
+ categoryArms(c),
101
+ [
102
+ ``,
103
+ `// Mirrors \`Reventless.Messaging.channel\`. Declared here as well because a`,
104
+ `// domain type travels on the wire and the capability's does not.`,
105
+ `@schema`,
106
+ `type channel =`,
107
+ ` | Email`,
108
+ ` | Sms`,
109
+ ` | Push`,
110
+ ``,
111
+ `// Its own past facts, and nothing else — which is what lets one component hold`,
112
+ `// the directory and make the dispatch decision.`,
113
+ `@schema`,
114
+ `type consumedEvent =`,
115
+ ` | ` + n.announced + `({` + id + `: string, ` + c.contactField + `: string})`,
116
+ ` | ` + n.subscribed + `({` + id + `: string, category: category, channel: channel})`,
117
+ ` | ` + n.unsubscribed + `({` + id + `: string, category: category, channel: channel})`,
118
+ ``,
119
+ `@schema`,
120
+ `type command =`,
121
+ ` // Relayed from this host's own announcements, never called by a client — a`,
122
+ ` // caller who could register somebody else's address would redirect their mail.`,
123
+ ` | @noApi ` + n.announceCmd + `({` + id + `: string, ` + c.contactField + `: string})`,
124
+ ` // The client door. \`@owner\` is what stops a caller managing another person's`,
125
+ ` // matrix: the resolver overwrites the field with the authenticated caller.`,
126
+ auth + n.subscribeCmd + `({@owner ` + id + `: string, category: category, channel: channel})`,
127
+ auth + n.unsubscribeCmd + `({@owner ` + id + `: string, category: category, channel: channel})`,
128
+ ` // Also relayed. \`reference\` is the requester's own key, echoed back on`,
129
+ ` // whichever outcome follows, so the relay can tell its work is finished.`,
130
+ ` | @noApi`,
131
+ ` ` + n.requestCmd + `({`,
132
+ ` ` + id + `: string,`,
133
+ ` category: category,`,
134
+ ` reference: string,`,
135
+ ` subject: string,`,
136
+ ` body: string,`,
137
+ ` })`,
138
+ ` // Reported by the send slice once the provider has settled.`,
139
+ ` | @noApi ` + n.recordCmd + `({` + id + `: string, reference: string, providerRef: string})`,
140
+ ` | @noApi ` + n.recordFailureCmd + `({` + id + `: string, reference: string, reason: string})`,
141
+ ``,
142
+ `@schema`,
143
+ `type error =`,
144
+ ` // A person is at the other end of the two client commands, so this is a`,
145
+ ` // refusal rather than a recorded fact: they can be told.`,
146
+ ` | ` + n.unknownError,
147
+ ``,
148
+ `@schema`,
149
+ `type event =`,
150
+ ` | ` + n.announced + `({` + id + `: string, ` + c.contactField + `: string})`,
151
+ ` | ` + n.subscribed + `({` + id + `: string, category: category, channel: channel})`,
152
+ ` | ` + n.unsubscribed + `({` + id + `: string, category: category, channel: channel})`,
153
+ ` // The addressed message. \`address\` is the snapshot delivery uses, which is`,
154
+ ` // why it is on the fact rather than looked up again later.`,
155
+ ` | ` + n.requested + `({`,
156
+ ` ` + id + `: string,`,
157
+ ` category: category,`,
158
+ ` reference: string,`,
159
+ ` channel: channel,`,
160
+ ` address: string,`,
161
+ ` subject: string,`,
162
+ ` body: string,`,
163
+ ` })`,
164
+ ` // Two different ways to send nothing. A ` + c.noun.toLowerCase() + ` who declined is the`,
165
+ ` // system working; one with no address for a channel they enabled is the system`,
166
+ ` // falling short, and one fact for both hides every gap behind a preference.`,
167
+ ` | ` + n.suppressed + `({` + id + `: string, category: category, reference: string})`,
168
+ ` | ` + n.undeliverable + `({` + id + `: string, category: category, reference: string})`,
169
+ ` | ` + n.delivered + `({` + id + `: string, reference: string, providerRef: string})`,
170
+ ` | ` + n.failed + `({` + id + `: string, reference: string, reason: string})`,
171
+ ``
172
+ ]
173
+ ].flatMap(x => x).join("\n");
174
+ }
175
+
176
+ function postureArms(c) {
177
+ let transactional = c.transactional.map(cat => ` | ("` + cat + `", Email) => true`);
178
+ return transactional.concat([` | _ => false`]);
179
+ }
180
+
181
+ function sliceBehavior(c) {
182
+ let n = namesOf(c);
183
+ let id = n.recipientId;
184
+ let first = Stdlib_Option.getOr(c.categories[0], "Transactional");
185
+ return [
186
+ [
187
+ `@@reventless.behavior`,
188
+ ``,
189
+ `// The directory, the matrix and the dispatch decision are the trait's; this file`,
190
+ `// is the mapping onto them, plus the one thing the trait has no opinion about —`,
191
+ `// whether an unheard-from ` + c.noun.toLowerCase() + ` gets a given kind.`,
192
+ `module Rules = TraitNotification.Notification_Rules`,
193
+ ``,
194
+ `// TODO(graft): the posture, per category. A kind an unheard-from ` + c.noun.toLowerCase(),
195
+ `// should still receive answers \`true\`; the rest are opt-in. Per category and`,
196
+ `// never globally — one default forces both kinds onto whichever answer is worse`,
197
+ `// for the other. Email only: a channel nobody chose is a channel no address was`,
198
+ `// given for.`,
199
+ `let posture = (category: string, channel: Rules.channel) =>`,
200
+ ` switch (category, channel) {`
201
+ ],
202
+ postureArms(c),
203
+ [
204
+ ` }`,
205
+ ``,
206
+ `let categoryKey = (category: category) =>`,
207
+ ` switch category {`
208
+ ],
209
+ c.categories.map(cat => ` | ` + cat + ` => "` + cat + `"`),
210
+ [
211
+ ` }`,
212
+ ``,
213
+ `let categoryOf = (key: string) =>`,
214
+ ` switch key {`
215
+ ],
216
+ c.categories.filter(cat => cat !== first).map(cat => ` | "` + cat + `" => ` + cat),
217
+ [
218
+ ` // A key this build does not know can only come from an event another version`,
219
+ ` // of this slice wrote. The first kind declared is the fallback.`,
220
+ ` | _ => ` + first,
221
+ ` }`,
222
+ ``,
223
+ `let channelKey = (channel: channel): Rules.channel =>`,
224
+ ` switch channel {`,
225
+ ` | Email => Email`,
226
+ ` | Sms => Sms`,
227
+ ` | Push => Push`,
228
+ ` }`,
229
+ ``,
230
+ `let channelOf = (channel: Rules.channel): channel =>`,
231
+ ` switch channel {`,
232
+ ` | Email => Email`,
233
+ ` | Sms => Sms`,
234
+ ` | Push => Push`,
235
+ ` }`,
236
+ ``,
237
+ `// The trait's own value, refolded per decision. The host stores nothing beside`,
238
+ `// it: what this plugin knows about a ` + c.noun.toLowerCase() + `'s preferences IS the directory.`,
239
+ `type state = Rules.t`,
240
+ ``,
241
+ `let initialState = Rules.empty`,
242
+ ``,
243
+ `let evolve = (state, event: consumedEvent) =>`,
244
+ ` switch event {`,
245
+ ` | ` + n.announced + `({` + c.contactField + `}) =>`,
246
+ ` state->Rules.evolve(Announced({channel: Email, address: ` + c.contactField + `}))`,
247
+ ` | ` + n.subscribed + `({category, channel}) =>`,
248
+ ` state->Rules.evolve(`,
249
+ ` Subscribed({category: categoryKey(category), channel: channelKey(channel)}),`,
250
+ ` )`,
251
+ ` | ` + n.unsubscribed + `({category, channel}) =>`,
252
+ ` state->Rules.evolve(`,
253
+ ` Unsubscribed({category: categoryKey(category), channel: channelKey(channel)}),`,
254
+ ` )`,
255
+ ` }`,
256
+ ``,
257
+ `// The trait decides; this names what it decided in the host's vocabulary.`,
258
+ `let named = (` + id + `, fact: Rules.fact) =>`,
259
+ ` switch fact {`,
260
+ ` | Announced({address}) => ` + n.announced + `({` + id + `: ` + id + `, ` + c.contactField + `: address})`,
261
+ ` | Subscribed({category, channel}) =>`,
262
+ ` ` + n.subscribed + `({`,
263
+ ` ` + id + `: ` + id + `,`,
264
+ ` category: categoryOf(category),`,
265
+ ` channel: channelOf(channel),`,
266
+ ` })`,
267
+ ` | Unsubscribed({category, channel}) =>`,
268
+ ` ` + n.unsubscribed + `({`,
269
+ ` ` + id + `: ` + id + `,`,
270
+ ` category: categoryOf(category),`,
271
+ ` channel: channelOf(channel),`,
272
+ ` })`,
273
+ ` | Requested({category, reference, channel, address}) =>`,
274
+ ` ` + n.requested + `({`,
275
+ ` ` + id + `: ` + id + `,`,
276
+ ` category: categoryOf(category),`,
277
+ ` reference,`,
278
+ ` channel: channelOf(channel),`,
279
+ ` address,`,
280
+ ` // Carried on the command and put back below: the trait holds no sentence.`,
281
+ ` subject: "",`,
282
+ ` body: "",`,
283
+ ` })`,
284
+ ` | Suppressed({category, reference}) =>`,
285
+ ` ` + n.suppressed + `({` + id + `: ` + id + `, category: categoryOf(category), reference})`,
286
+ ` | Undeliverable({category, reference}) =>`,
287
+ ` ` + n.undeliverable + `({` + id + `: ` + id + `, category: categoryOf(category), reference})`,
288
+ ` }`,
289
+ ``,
290
+ `let through = (state, ` + id + `, op) =>`,
291
+ ` switch state->Rules.decide(op, ~posture) {`,
292
+ ` | Ok(facts) => Ok(facts->Array.map(named(` + id + `, _)))`,
293
+ ` | Error(#RecipientUnknown) => Error(` + n.unknownError + `)`,
294
+ ` }`,
295
+ ``,
296
+ `let decide = (state, command) =>`,
297
+ ` switch command {`,
298
+ ` | ` + n.announceCmd + `({` + id + `, ` + c.contactField + `}) =>`,
299
+ ` through(state, ` + id + `, Announce({channel: Email, address: ` + c.contactField + `}))`,
300
+ ` | ` + n.subscribeCmd + `({` + id + `, category, channel}) =>`,
301
+ ` through(`,
302
+ ` state,`,
303
+ ` ` + id + `,`,
304
+ ` Subscribe({category: categoryKey(category), channel: channelKey(channel)}),`,
305
+ ` )`,
306
+ ` | ` + n.unsubscribeCmd + `({` + id + `, category, channel}) =>`,
307
+ ` through(`,
308
+ ` state,`,
309
+ ` ` + id + `,`,
310
+ ` Unsubscribe({category: categoryKey(category), channel: channelKey(channel)}),`,
311
+ ` )`,
312
+ ``,
313
+ ` // The one arm that is not a rename: the words belong to the requester, and the`,
314
+ ` // trait's fact does not carry them, so they are put back on the way out.`,
315
+ ` | ` + n.requestCmd + `({` + id + `, category, reference, subject, body}) =>`,
316
+ ` through(state, ` + id + `, Request({category: categoryKey(category), reference}))`,
317
+ ` ->Result.map(events =>`,
318
+ ` events->Array.map(event =>`,
319
+ ` switch event {`,
320
+ ` | ` + n.requested + `(fields) => ` + n.requested + `({...fields, subject, body})`,
321
+ ` | other => other`,
322
+ ` }`,
323
+ ` )`,
324
+ ` )`,
325
+ ``,
326
+ ` // No rule to state — the outcome is whatever the provider said.`,
327
+ ` | ` + n.recordCmd + `({` + id + `, reference, providerRef}) =>`,
328
+ ` Ok([` + n.delivered + `({` + id + `: ` + id + `, reference, providerRef})])`,
329
+ ` | ` + n.recordFailureCmd + `({` + id + `, reference, reason}) =>`,
330
+ ` Ok([` + n.failed + `({` + id + `: ` + id + `, reference, reason})])`,
331
+ ` }`,
332
+ ``
333
+ ]
334
+ ].flatMap(x => x).join("\n");
335
+ }
336
+
337
+ function sendSpec(c) {
338
+ let n = namesOf(c);
339
+ let id = n.recipientId;
340
+ return [
341
+ `// ` + n.send + ` OutboundTranslationSlice: delivers a message that has already`,
342
+ `// been addressed and already been decided on. Everything it needs is on the`,
343
+ `// request — the channel, the address as of the moment the message was composed,`,
344
+ `// and the words — so it reads no state and knows nothing about who anybody is.`,
345
+ `//`,
346
+ `// Emitted by the trait; ordinary source from here on.`,
347
+ ``,
348
+ `@@reventless.spec`,
349
+ ``,
350
+ `// Only the addressed request. The other two outcomes are decisions not to send.`,
351
+ `@schema`,
352
+ `type consumedEvent =`,
353
+ ` | ` + n.requested + `({`,
354
+ ` ` + id + `: string,`,
355
+ ` reference: string,`,
356
+ ` channel: ` + n.slice + `.channel,`,
357
+ ` address: string,`,
358
+ ` subject: string,`,
359
+ ` body: string,`,
360
+ ` })`,
361
+ ``,
362
+ `@schema`,
363
+ `type outboundItem = {`,
364
+ ` ` + id + `: string,`,
365
+ ` reference: string,`,
366
+ ` channel: ` + n.slice + `.channel,`,
367
+ ` address: string,`,
368
+ ` subject: string,`,
369
+ ` body: string,`,
370
+ `}`,
371
+ ``,
372
+ `@schema`,
373
+ `type inboundCommand =`,
374
+ ` | ` + n.recordCmd + `({` + id + `: string, reference: string, providerRef: string})`,
375
+ ` | ` + n.recordFailureCmd + `({` + id + `: string, reference: string, reason: string})`,
376
+ ``,
377
+ `// Retries are for a provider that is down, not one that has refused. The port's`,
378
+ `// own \`retriable\` rule decides which is which, in the translation next door.`,
379
+ `let maxRetries = 3`,
380
+ `let heartbeatInterval = 60`,
381
+ `let targetName = Some("` + n.slice + `")`,
382
+ ``,
383
+ `// This plugin's own DCB event log.`,
384
+ `let sourceNames: array<string> = []`,
385
+ ``,
386
+ `// Named for the capability rather than a provider: which one is behind it is`,
387
+ `// the deployment's decision, and this slice never learns the answer.`,
388
+ `let externalSystem = Some("Messaging")`,
389
+ ``,
390
+ `// Declared, so a deployment that provisions no sender fails rather than queueing`,
391
+ `// every message until it is abandoned.`,
392
+ `let capabilityNeeds = TraitNotification.Notification.capabilityNeeds`,
393
+ `// The graft's own record of itself — see the trait's \`declaration\`.`,
394
+ `let traits = [TraitNotification.Notification.declaration]`,
395
+ ``
396
+ ].join("\n");
397
+ }
398
+
399
+ function sendTranslation(c) {
400
+ let n = namesOf(c);
401
+ let id = n.recipientId;
402
+ return [
403
+ `@@reventless.translation`,
404
+ ``,
405
+ `// Keyed by the request's own reference: one message per decision, and a`,
406
+ `// redelivery lands on the row that is already there.`,
407
+ `let collect = (event, ~sourceId as _) =>`,
408
+ ` switch event {`,
409
+ ` | ` + n.requested + `({` + id + `, reference, channel, address, subject, body}) => [`,
410
+ ` (reference, {` + id + `: ` + id + `, reference, channel, address, subject, body}),`,
411
+ ` ]`,
412
+ ` }`,
413
+ ``,
414
+ `// The one place the domain's channel vocabulary meets the platform's. Two`,
415
+ `// declarations rather than one shared type because a schema type travels on the`,
416
+ `// wire and the capability's does not — and this switch is where a channel the`,
417
+ `// platform grows shows up as a compile error rather than as a silence.`,
418
+ `let recipientFor = (item: outboundItem) =>`,
419
+ ` switch item.channel {`,
420
+ ` | Email =>`,
421
+ ` item.address`,
422
+ ` ->Reventless.Email.fromString`,
423
+ ` ->Result.map(email => Reventless.Messaging.ToEmail(email))`,
424
+ ` | Sms =>`,
425
+ ` item.address`,
426
+ ` ->Reventless.Phone.fromString`,
427
+ ` ->Result.map(phone => Reventless.Messaging.ToSms(phone))`,
428
+ ` | Push => Ok(Reventless.Messaging.ToPush({deviceToken: item.address}))`,
429
+ ` }`,
430
+ ``,
431
+ `let translate = async (_id, item: outboundItem, ~capabilities: Reventless.Capabilities.t) =>`,
432
+ ` switch recipientFor(item) {`,
433
+ ` // An address the directory holds that its own channel's grammar refuses. Not`,
434
+ ` // retryable and not the provider's fault — the row that holds it needs fixing.`,
435
+ ` | Error(why) =>`,
436
+ ` Ok(`,
437
+ ` Some((`,
438
+ ` item.` + id + `,`,
439
+ ` ` + n.recordFailureCmd + `({`,
440
+ ` ` + id + `: item.` + id + `,`,
441
+ ` reference: item.reference,`,
442
+ ` reason: why,`,
443
+ ` }),`,
444
+ ` )),`,
445
+ ` )`,
446
+ ` | Ok(recipient) =>`,
447
+ ` switch await capabilities.messaging.send(`,
448
+ ` ~recipient,`,
449
+ ` ~message={subject: item.subject, body: item.body},`,
450
+ ` ) {`,
451
+ ` | Ok({ref}) =>`,
452
+ ` Ok(`,
453
+ ` Some((`,
454
+ ` item.` + id + `,`,
455
+ ` ` + n.recordCmd + `({`,
456
+ ` ` + id + `: item.` + id + `,`,
457
+ ` reference: item.reference,`,
458
+ ` providerRef: ref,`,
459
+ ` }),`,
460
+ ` )),`,
461
+ ` )`,
462
+ ` // The retry split, taken from the port rather than re-decided here.`,
463
+ ` | Error(failure) =>`,
464
+ ` Reventless.Messaging.retriable(failure)`,
465
+ ` ? Error(Reventless.Messaging.failureReason(failure))`,
466
+ ` : Ok(`,
467
+ ` Some((`,
468
+ ` item.` + id + `,`,
469
+ ` ` + n.recordFailureCmd + `({`,
470
+ ` ` + id + `: item.` + id + `,`,
471
+ ` reference: item.reference,`,
472
+ ` reason: Reventless.Messaging.failureReason(failure),`,
473
+ ` }),`,
474
+ ` )),`,
475
+ ` )`,
476
+ ` }`,
477
+ ` }`,
478
+ ``,
479
+ `// The budget is spent and the provider never answered. Recording it beats leaving`,
480
+ `// the row pending forever — and it is the second reason the capability must be`,
481
+ `// declared, since an unprovisioned sender reaches here every single time.`,
482
+ `let onExhausted = (_id, item: outboundItem, ~lastError) =>`,
483
+ ` Some((`,
484
+ ` item.` + id + `,`,
485
+ ` ` + n.recordFailureCmd + `({`,
486
+ ` ` + id + `: item.` + id + `,`,
487
+ ` reference: item.reference,`,
488
+ ` reason: lastError->Option.getOr("the messaging provider never answered"),`,
489
+ ` }),`,
490
+ ` ))`,
491
+ ``
492
+ ].join("\n");
493
+ }
494
+
495
+ function conformanceBinding(c) {
496
+ let n = namesOf(c);
497
+ let id = n.recipientId;
498
+ let first = Stdlib_Option.getOr(c.categories[0], "Transactional");
499
+ let optional = Stdlib_Option.getOr(c.categories.find(cat => !c.transactional.includes(cat)), "Marketing");
500
+ let transactional = Stdlib_Option.getOr(c.transactional[0], first);
501
+ return [
502
+ `// The notification trait's own suite, run against this host's graft.`,
503
+ `//`,
504
+ `// Everything here is the trait's: the directory, the fallback to this host's`,
505
+ `// posture, and the three different facts that mean "nothing was sent". None of it`,
506
+ `// belongs in the slice's own GWT as well — a rule the suite covers must not live`,
507
+ `// in two places, or the two drift and neither is the source of truth.`,
508
+ ``,
509
+ `module Binding = {`,
510
+ ` type category = ` + n.slice + `.category`,
511
+ ` let transactional: category = ` + transactional,
512
+ ` let optional: category = ` + optional,
513
+ ``,
514
+ ` module Spec = ` + n.slice,
515
+ ` module Behavior = ` + n.slice + `_Behavior`,
516
+ ``,
517
+ ` // A DCB slice's entity comes into existence with its first fact, so there is`,
518
+ ` // no creation event to seed: an unannounced ` + c.noun.toLowerCase() + ` is one with no history.`,
519
+ ` let created: array<Spec.consumedEvent> = []`,
520
+ ``,
521
+ ` let ` + id + ` = "` + c.noun.toLowerCase() + `-1"`,
522
+ ``,
523
+ ` // Annotated: this slice reads back exactly what it writes, so each of these`,
524
+ ` // names a constructor of both unions and the later declaration would win.`,
525
+ ` let announcedC = (` + c.contactField + `): Spec.consumedEvent =>`,
526
+ ` ` + n.announced + `({` + id + `: ` + id + `, ` + c.contactField + `: ` + c.contactField + `})`,
527
+ ` let subscribedC = (category, channel): Spec.consumedEvent =>`,
528
+ ` ` + n.subscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
529
+ ` let unsubscribedC = (category, channel): Spec.consumedEvent =>`,
530
+ ` ` + n.unsubscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
531
+ ``,
532
+ ` let announce = ` + c.contactField + ` =>`,
533
+ ` Spec.` + n.announceCmd + `({` + id + `: ` + id + `, ` + c.contactField + `: ` + c.contactField + `})`,
534
+ ` let subscribe = (category, channel) =>`,
535
+ ` Spec.` + n.subscribeCmd + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
536
+ ` let unsubscribe = (category, channel) =>`,
537
+ ` Spec.` + n.unsubscribeCmd + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
538
+ ` // The wording is this host's and the trait carries none, so the suite supplies`,
539
+ ` // whatever it likes and asserts nothing about it.`,
540
+ ` let request = (category, reference) =>`,
541
+ ` Spec.` + n.requestCmd + `({`,
542
+ ` ` + id + `: ` + id + `,`,
543
+ ` category,`,
544
+ ` reference,`,
545
+ ` subject: "subject",`,
546
+ ` body: "body",`,
547
+ ` })`,
548
+ ``,
549
+ ` let announced = ` + c.contactField + ` =>`,
550
+ ` Spec.` + n.announced + `({` + id + `: ` + id + `, ` + c.contactField + `: ` + c.contactField + `})`,
551
+ ` let subscribed = (category, channel) =>`,
552
+ ` Spec.` + n.subscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
553
+ ` let unsubscribed = (category, channel) =>`,
554
+ ` Spec.` + n.unsubscribed + `({` + id + `: ` + id + `, category, channel: Behavior.channelOf(channel)})`,
555
+ ` let requested = (category, reference, channel, address) =>`,
556
+ ` Spec.` + n.requested + `({`,
557
+ ` ` + id + `: ` + id + `,`,
558
+ ` category,`,
559
+ ` reference,`,
560
+ ` channel: Behavior.channelOf(channel),`,
561
+ ` address,`,
562
+ ` subject: "subject",`,
563
+ ` body: "body",`,
564
+ ` })`,
565
+ ` let suppressed = (category, reference) =>`,
566
+ ` Spec.` + n.suppressed + `({` + id + `: ` + id + `, category, reference})`,
567
+ ` let undeliverable = (category, reference) =>`,
568
+ ` Spec.` + n.undeliverable + `({` + id + `: ` + id + `, category, reference})`,
569
+ ``,
570
+ ` let recipientUnknown = Spec.` + n.unknownError,
571
+ ``,
572
+ ` let addressA = "` + Stdlib_Option.getOr(c.addressA, "buyer@example.com") + `"`,
573
+ ` let addressB = "` + Stdlib_Option.getOr(c.addressB, "new@example.com") + `"`,
574
+ ` let announcedChannel: TraitNotification.Notification_Rules.channel = Email`,
575
+ ` // This host announces an inbox and nothing else, so SMS is a channel somebody`,
576
+ ` // can want and not be reached on — the state the undeliverable arm exists for.`,
577
+ ` let unreachableChannel = Some(TraitNotification.Notification_Rules.Sms)`,
578
+ `}`,
579
+ ``,
580
+ `module Conformance = TraitNotification.Notification_Conformance.Make(Binding)`,
581
+ ``,
582
+ `Conformance.register()`,
583
+ ``
584
+ ].join("\n");
585
+ }
586
+
587
+ function contactRelayPatch(c) {
588
+ let n = namesOf(c);
589
+ let id = n.recipientId;
590
+ return {
591
+ into: c.chapter + `/OutboundTranslationSlice/` + n.relay + `.res (new, plus its _Translation)`,
592
+ at: `a new file — the trait cannot write what this host's events mean`,
593
+ contents: [
594
+ [
595
+ `// Relays this host's contact announcements into the directory, so somebody is`,
596
+ `// reachable because they registered rather than because they visited a screen.`,
597
+ `//`,
598
+ `// An OutboundTranslationSlice rather than an automation, and not by preference:`,
599
+ `// an automation's collect is handed the event and the ambient context and`,
600
+ `// nothing else, and an aggregate's event does not repeat the id that addressed`,
601
+ `// it. This is the one component the framework passes a ~sourceId to.`,
602
+ ``,
603
+ `@@reventless.spec`,
604
+ ``,
605
+ `// TODO(graft): every event of ` + c.contactSource + ` that carries a contact. All of them —`,
606
+ `// an address that changed and was not relayed leaves the directory writing to`,
607
+ `// the old one.`,
608
+ `@schema`,
609
+ `type consumedEvent =`
610
+ ],
611
+ c.contactEvents.map(e => ` | ` + e + `({` + c.contactField + `: string})`),
612
+ [
613
+ ``,
614
+ `@schema`,
615
+ `type outboundItem = {` + id + `: string, ` + c.contactField + `: string}`,
616
+ ``,
617
+ `@schema`,
618
+ `type inboundCommand =`,
619
+ ` | ` + n.announceCmd + `({` + id + `: string, ` + c.contactField + `: string})`,
620
+ ``,
621
+ `let maxRetries = 3`,
622
+ `let heartbeatInterval = 60`,
623
+ `let targetName = Some("` + n.slice + `")`,
624
+ `let sourceNames = ["` + c.contactSource + `"]`,
625
+ `// No external box: this slice reaches nothing outside the plugin.`,
626
+ `let externalSystem = None`,
627
+ `let capabilityNeeds: array<Reventless.CapabilityNeed.t> = []`,
628
+ `let traits = [TraitNotification.Notification.declaration]`,
629
+ ``,
630
+ `// ── ` + n.relay + `_Translation.res ──`,
631
+ ``,
632
+ `@@reventless.translation`,
633
+ ``,
634
+ `// Keyed by entity AND address: keying by entity alone would make a later`,
635
+ `// change look like work already done.`,
636
+ `let collect = (event, ~sourceId) =>`,
637
+ ` switch event {`
638
+ ],
639
+ c.contactEvents.map(e => ` | ` + e + `({` + c.contactField + `}) => [(\`\${sourceId}:\${` + c.contactField + `}\`, {` + id + `: sourceId, ` + c.contactField + `: ` + c.contactField + `})]`),
640
+ [
641
+ ` }`,
642
+ ``,
643
+ `// No service to call. The item completes on this returning Ok, which is what`,
644
+ `// lets the directory's own announce command be idempotent: a re-announced`,
645
+ `// address publishes no event, and no row waits for one.`,
646
+ `let translate = async (_id, item: outboundItem, ~capabilities as _) =>`,
647
+ ` Ok(`,
648
+ ` Some((`,
649
+ ` item.` + id + `,`,
650
+ ` ` + n.announceCmd + `({` + id + `: item.` + id + `, ` + c.contactField + `: item.` + c.contactField + `}),`,
651
+ ` )),`,
652
+ ` )`,
653
+ ``,
654
+ `let onExhausted = (_id, _item, ~lastError as _) => None`
655
+ ]
656
+ ].flatMap(x => x).join("\n")
657
+ };
658
+ }
659
+
660
+ function intakeRelayPatch(c) {
661
+ let n = namesOf(c);
662
+ let id = n.recipientId;
663
+ return {
664
+ into: c.chapter + `/AutomationSlice/` + n.intake + `.res (new, plus its _Automation)`,
665
+ at: `a new file — the wording is this host's sentence, not the trait's`,
666
+ contents: [
667
+ `// Turns one of this host's occurrences into a request to notify somebody.`,
668
+ ``,
669
+ `@@reventless.spec`,
670
+ ``,
671
+ `@schema`,
672
+ `type todoItem = {` + id + `: string, ` + c.occurrenceId + `: string}`,
673
+ ``,
674
+ `@schema`,
675
+ `type command =`,
676
+ ` ` + n.requestCmd + `({`,
677
+ ` ` + id + `: string,`,
678
+ ` category: ` + n.slice + `.category,`,
679
+ ` reference: string,`,
680
+ ` subject: string,`,
681
+ ` body: string,`,
682
+ ` })`,
683
+ ``,
684
+ `let maxRetries = 3`,
685
+ `let heartbeatInterval = 60`,
686
+ `let targetName = "` + n.slice + `"`,
687
+ ``,
688
+ `// ── ` + n.intake + `_Automation.res ──`,
689
+ ``,
690
+ `@@reventless.automation`,
691
+ ``,
692
+ `// A TODO id is also the reference the request carries, so the outcome event`,
693
+ `// echoes back exactly what resolves the row.`,
694
+ `let key = ` + c.occurrenceId + ` => \`notify:\${` + c.occurrenceId + `}\``,
695
+ ``,
696
+ `module DcbSource = {`,
697
+ ` // MUST equal "<pluginName>DcbEventLog".`,
698
+ ` let name = "TODO(graft)DcbEventLog"`,
699
+ ``,
700
+ ` @schema`,
701
+ ` type event =`,
702
+ ` | ` + c.occurrence + `({` + c.occurrenceId + `: string, ` + c.occurrenceRecipient + `: string})`,
703
+ ` // All three resolve the row: a suppressed notification is finished work,`,
704
+ ` // not failed work.`,
705
+ ` | ` + n.requested + `({reference: string})`,
706
+ ` | ` + n.suppressed + `({reference: string})`,
707
+ ` | ` + n.undeliverable + `({reference: string})`,
708
+ `}`,
709
+ ``,
710
+ `module FromDcb = Mapping.Make(`,
711
+ ` DcbSource,`,
712
+ ` ` + n.intake + `,`,
713
+ ` {`,
714
+ ` open DcbSource`,
715
+ ``,
716
+ ` let collect = (event, _ctx) =>`,
717
+ ` switch event {`,
718
+ ` | ` + c.occurrence + `({` + c.occurrenceId + `, ` + c.occurrenceRecipient + `}) => [`,
719
+ ` (`,
720
+ ` key(` + c.occurrenceId + `),`,
721
+ ` ({` + id + `: ` + c.occurrenceRecipient + `, ` + c.occurrenceId + `: ` + c.occurrenceId + `}: ` + n.intake + `.todoItem),`,
722
+ ` ),`,
723
+ ` ]`,
724
+ ` | ` + n.requested + `(_)`,
725
+ ` | ` + n.suppressed + `(_)`,
726
+ ` | ` + n.undeliverable + `(_) => []`,
727
+ ` }`,
728
+ ``,
729
+ ` let resolve = event =>`,
730
+ ` switch event {`,
731
+ ` | ` + n.requested + `({reference})`,
732
+ ` | ` + n.suppressed + `({reference})`,
733
+ ` | ` + n.undeliverable + `({reference}) =>`,
734
+ ` Some(reference)`,
735
+ ` | ` + c.occurrence + `(_) => None`,
736
+ ` }`,
737
+ ` },`,
738
+ `)`,
739
+ ``,
740
+ `let mappings: array<module(Mapping)> = [module(FromDcb)]`,
741
+ ``,
742
+ `// TODO(graft): the wording. A trait declares the kind; what the sentence says`,
743
+ `// is this host's, and a config field for it would be a template language.`,
744
+ `let process = (id, item: ` + n.intake + `.todoItem) =>`,
745
+ ` Some((`,
746
+ ` item.` + id + `,`,
747
+ ` ` + n.intake + `.` + n.requestCmd + `({`,
748
+ ` ` + id + `: item.` + id + `,`,
749
+ ` category: ` + c.occurrenceCategory + `,`,
750
+ ` reference: id,`,
751
+ ` subject: "TODO(graft)",`,
752
+ ` body: "TODO(graft)",`,
753
+ ` }),`,
754
+ ` ))`,
755
+ ``,
756
+ `// A relay that gave up published no command, so the competency never heard of`,
757
+ `// the occurrence — a delivery-failed fact for a message nobody requested would`,
758
+ `// put a row in the log for something that was never attempted.`,
759
+ `let onExhausted = (_id, _item) => None`
760
+ ].join("\n")
761
+ };
762
+ }
763
+
764
+ function emit(config, into, tests) {
765
+ let n = namesOf(config);
766
+ return {
767
+ files: [
768
+ {
769
+ path: into + `/StateChangeSlice/` + n.slice + `.res`,
770
+ contents: sliceSpec(config)
771
+ },
772
+ {
773
+ path: into + `/StateChangeSlice/` + n.slice + `_Behavior.res`,
774
+ contents: sliceBehavior(config)
775
+ },
776
+ {
777
+ path: into + `/OutboundTranslationSlice/` + n.send + `.res`,
778
+ contents: sendSpec(config)
779
+ },
780
+ {
781
+ path: into + `/OutboundTranslationSlice/` + n.send + `_Translation.res`,
782
+ contents: sendTranslation(config)
783
+ },
784
+ {
785
+ path: tests + `/NotificationConformance_GWT.res`,
786
+ contents: conformanceBinding(config)
787
+ }
788
+ ],
789
+ patches: [
790
+ contactRelayPatch(config),
791
+ intakeRelayPatch(config)
792
+ ]
793
+ };
794
+ }
795
+
796
+ export {
797
+ configSchema,
798
+ namesOf,
799
+ lines,
800
+ addressA,
801
+ addressB,
802
+ authorizeOn,
803
+ categoryArms,
804
+ sliceSpec,
805
+ postureArms,
806
+ sliceBehavior,
807
+ sendSpec,
808
+ sendTranslation,
809
+ conformanceBinding,
810
+ contactRelayPatch,
811
+ intakeRelayPatch,
812
+ emit,
813
+ }
814
+ /* configSchema Not a pure module */